mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
logicsrc CLI 0.2.0: every LogicSRC tool is a word after logicsrc (#157)
OpenContext, OpenCreds, OpenOntology and OpenPRD were already registered under the umbrella. Three were not: - `logicsrc openmcp …`: OpenMCP lives in its own repository, so it is a dependency (@logicsrc/openmcp ^0.3.1, which exports ./cli for this) and every argument goes untouched to the same main the standalone binary runs. Imported on first use, because the catalog is node:sqlite (Node 24) while the rest of the CLI runs on 18; below the floor that one word says so and offers the standalone installer, which brings its own Node. - `logicsrc openspec <anything else>`: import, export and change stay ours; any other word (init, list, validate, archive, show) runs OpenSpec.dev's own CLI (@fission-ai/openspec) as the group's default subcommand, flags intact. One command for a repo in compatibility mode, and the OpenSpec.dev half is upstream itself rather than a copy that would drift. - `logicsrc mcp`: the LogicSRC MCP server (@profullstack/logicsrc-mcp) over stdio, spawned as a child because it owns the process's stdio. build:cli now builds that workspace. README gets a table of every word and the standalone name it mirrors; the OpenMCP spec page and doc mention the umbrella form. Claude-Session: https://claude.ai/code/session_01Qh2dieNyPZ4Hx5g3XNJ1Eo Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0f6326e910
commit
8951e824d5
11 changed files with 1466 additions and 12 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@logicsrc/cli",
|
||||
"version": "0.1.2",
|
||||
"description": "LogicSRC OpenSpec CLI.",
|
||||
"version": "0.2.0",
|
||||
"description": "LogicSRC CLI: every LogicSRC standard and tool as one command.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
|
@ -14,9 +14,11 @@
|
|||
"test": "vitest run src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fission-ai/openspec": "^1.13.0",
|
||||
"@logicsrc/account-core": "file:../account-core",
|
||||
"@logicsrc/opencontext": "file:../opencontext",
|
||||
"@logicsrc/opencreds": "file:../opencreds",
|
||||
"@logicsrc/openmcp": "^0.3.1",
|
||||
"@logicsrc/openontology": "file:../openontology",
|
||||
"@logicsrc/openprd": "file:../openprd",
|
||||
"@logicsrc/plugin-agentbbs": "file:../../plugins/agentbbs",
|
||||
|
|
@ -30,6 +32,7 @@
|
|||
"@logicsrc/tui": "file:../tui",
|
||||
"@logicsrc/validators": "file:../validators",
|
||||
"@profullstack/hqtui": "^0.3.0",
|
||||
"@profullstack/logicsrc-mcp": "file:../logicsrc-mcp",
|
||||
"commander": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ import { registerOpenContextCommands } from "./context.js";
|
|||
import { registerOpenCredsCommands } from "./creds.js";
|
||||
import { registerOntologyCommands } from "./ontology.js";
|
||||
import { registerPrdCommands } from "./prd.js";
|
||||
import { registerOpenMcpCommands } from "./openmcp.js";
|
||||
import { registerOpenSpecDevPassthrough } from "./openspec-dev.js";
|
||||
import { registerMcpCommands } from "./mcp.js";
|
||||
import { defaultPluginRegistry } from "./registry.js";
|
||||
import {
|
||||
GH_REPO,
|
||||
|
|
@ -300,7 +303,12 @@ program
|
|||
}, "json");
|
||||
});
|
||||
|
||||
const openspec = program.command("openspec").description("Import, export, and generate OpenSpec.dev-compatible repo-local planning artifacts.");
|
||||
const openspec = program
|
||||
.command("openspec")
|
||||
.description(
|
||||
"Import, export, and generate OpenSpec.dev-compatible repo-local planning artifacts; any other word " +
|
||||
"(init, list, validate, archive, show, ...) runs OpenSpec.dev's own CLI.",
|
||||
);
|
||||
|
||||
openspec
|
||||
.command("import")
|
||||
|
|
@ -1049,6 +1057,10 @@ registerOpenContextCommands(program);
|
|||
registerOpenCredsCommands(program);
|
||||
registerOntologyCommands(program);
|
||||
registerPrdCommands(program);
|
||||
registerOpenMcpCommands(program);
|
||||
registerMcpCommands(program);
|
||||
// Every other word under `logicsrc openspec` is OpenSpec.dev's own CLI.
|
||||
registerOpenSpecDevPassthrough(openspec);
|
||||
|
||||
program.parseAsync(process.argv).catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
|
|
|
|||
50
packages/cli/src/mcp.ts
Normal file
50
packages/cli/src/mcp.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import type { Command } from "commander";
|
||||
|
||||
/**
|
||||
* `logicsrc mcp`
|
||||
*
|
||||
* The LogicSRC MCP server (`@profullstack/logicsrc-mcp`, the standalone
|
||||
* `logicsrc-mcp` binary): schemas, prompts, validators, OpenOntology and
|
||||
* OpenPRD over stdio, for an agent client's config. It is the last LogicSRC
|
||||
* command that was only reachable under its own name; now every one of them
|
||||
* is a word after `logicsrc`.
|
||||
*
|
||||
* Spawned as a child on stdio rather than imported, because the server owns
|
||||
* the process's stdin and stdout for the lifetime of the session, and that is
|
||||
* not something a subcommand should do inside the CLI process that also
|
||||
* prints help and errors.
|
||||
*/
|
||||
export const LOGICSRC_MCP_PACKAGE = "@profullstack/logicsrc-mcp";
|
||||
|
||||
/** The server's entry, which is also its bin. */
|
||||
export function logicsrcMcpBin(resolveEntry: (id: string) => string = createRequire(import.meta.url).resolve): string {
|
||||
return resolveEntry(LOGICSRC_MCP_PACKAGE);
|
||||
}
|
||||
|
||||
export function runLogicsrcMcp(args: string[], bin: string = logicsrcMcpBin()): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [bin, ...args], { stdio: "inherit" });
|
||||
child.on("error", (error) => {
|
||||
console.error(`Could not start the LogicSRC MCP server at ${bin}: ${error.message}`);
|
||||
resolve(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerMcpCommands(program: Command, run: (args: string[]) => Promise<number> = runLogicsrcMcp): void {
|
||||
program
|
||||
.command("mcp")
|
||||
.description(
|
||||
"Run the LogicSRC MCP server over stdio: schemas, prompts, validators, OpenOntology and OpenPRD for an " +
|
||||
"agent client. The same server as the standalone `logicsrc-mcp` command.",
|
||||
)
|
||||
.argument("[args...]", "Arguments for logicsrc-mcp, exactly as the standalone command takes them")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.action(async (args: string[]) => {
|
||||
process.exitCode = await run(args);
|
||||
});
|
||||
}
|
||||
63
packages/cli/src/openmcp.ts
Normal file
63
packages/cli/src/openmcp.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { Command } from "commander";
|
||||
|
||||
/**
|
||||
* `logicsrc openmcp …`
|
||||
*
|
||||
* OpenMCP is the one LogicSRC command that lives outside this monorepo
|
||||
* (github.com/logicsrc/openmcp, published as `@logicsrc/openmcp`), so unlike
|
||||
* `vault`, `prd` and `context` it cannot register commander commands here.
|
||||
* Instead every argument after `openmcp` is handed, untouched, to the same
|
||||
* `main(argv)` the standalone `openmcp` binary runs, so `logicsrc openmcp
|
||||
* relays` and `openmcp relays` are one contract and can never drift.
|
||||
*
|
||||
* The import is deferred to the action because the catalog keeps its records
|
||||
* in `node:sqlite`, which is Node 24, while the rest of this CLI runs on 18.
|
||||
* A box on an older Node keeps every other command and gets a plain sentence
|
||||
* from this one instead of a stack trace about a built-in it has never heard
|
||||
* of.
|
||||
*/
|
||||
export const OPENMCP_PACKAGE = "@logicsrc/openmcp";
|
||||
export const OPENMCP_MIN_NODE = 24;
|
||||
|
||||
export function openmcpNodeMessage(version: string = process.version): string {
|
||||
return [
|
||||
`logicsrc openmcp needs Node ${OPENMCP_MIN_NODE} or newer, and this is ${version}.`,
|
||||
" mise use -g node@lts # then re-run",
|
||||
" curl -fsSL https://openmcp.logicsrc.com/install.sh | sh # or the standalone command, which brings its own Node 24",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Is this Node new enough for the catalog? */
|
||||
export function meetsOpenmcpNode(version: string = process.version): boolean {
|
||||
return Number.parseInt(version.replace(/^v/, ""), 10) >= OPENMCP_MIN_NODE;
|
||||
}
|
||||
|
||||
type OpenmcpMain = (argv: string[]) => Promise<number>;
|
||||
|
||||
/** Loaded on first use, and swapped out in tests. */
|
||||
export async function loadOpenmcpMain(): Promise<OpenmcpMain> {
|
||||
const mod = (await import(`${OPENMCP_PACKAGE}/cli`)) as { main: OpenmcpMain };
|
||||
return mod.main;
|
||||
}
|
||||
|
||||
export function registerOpenMcpCommands(program: Command, load: () => Promise<OpenmcpMain> = loadOpenmcpMain): void {
|
||||
program
|
||||
.command("openmcp")
|
||||
.description(
|
||||
"OpenMCP: the open catalog of MCP relays. relays, find, call, add, probe, serve, and the rest of the " +
|
||||
"standalone `openmcp` command, handed through as typed. `logicsrc openmcp help` lists them.",
|
||||
)
|
||||
.argument("[args...]", "Arguments for openmcp, exactly as the standalone command takes them")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.helpOption(false)
|
||||
.action(async (args: string[]) => {
|
||||
if (!meetsOpenmcpNode()) {
|
||||
console.error(openmcpNodeMessage());
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const main = await load();
|
||||
process.exitCode = await main(args.length ? args : ["help"]);
|
||||
});
|
||||
}
|
||||
60
packages/cli/src/openspec-dev.ts
Normal file
60
packages/cli/src/openspec-dev.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
|
||||
/**
|
||||
* `logicsrc openspec <anything OpenSpec.dev understands>`
|
||||
*
|
||||
* `logicsrc openspec` owns three words of its own (`import`, `export`,
|
||||
* `change`: the LogicSRC-shaped view of a repo's OpenSpec artifacts). Every
|
||||
* other word is OpenSpec.dev's, and goes to OpenSpec.dev's real CLI
|
||||
* (`@fission-ai/openspec`, the `openspec` binary) with the arguments exactly
|
||||
* as typed: `logicsrc openspec init`, `list`, `validate`, `archive`, `show`.
|
||||
* So a repo in compatibility mode needs one command installed, not two, and
|
||||
* the OpenSpec.dev half is the upstream tool itself rather than a
|
||||
* reimplementation of it that would drift.
|
||||
*
|
||||
* Spawned rather than imported: the package exports a library entry only, and
|
||||
* its bin does the argv parsing, colour and prompts. Its bin is not on the
|
||||
* exports map, so it is found from the package root instead of resolved by
|
||||
* name.
|
||||
*/
|
||||
export const OPENSPEC_PACKAGE = "@fission-ai/openspec";
|
||||
|
||||
/** Where OpenSpec.dev's bin lives, found from its package root. */
|
||||
export function openspecBin(resolveEntry: (id: string) => string = createRequire(import.meta.url).resolve): string {
|
||||
// The only export is dist/index.js; bin/openspec.js sits beside dist/.
|
||||
const entry = resolveEntry(OPENSPEC_PACKAGE);
|
||||
return join(dirname(dirname(entry)), "bin", "openspec.js");
|
||||
}
|
||||
|
||||
export function runOpenspec(args: string[], bin: string = openspecBin()): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [bin, ...args], { stdio: "inherit" });
|
||||
child.on("error", (error) => {
|
||||
console.error(`Could not start OpenSpec.dev's CLI at ${bin}: ${error.message}`);
|
||||
resolve(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the pass-through as the group's default subcommand, so any word
|
||||
* commander does not know (`init`, `list`, `validate`, ...) lands here with
|
||||
* its arguments intact, while `import`, `export` and `change` keep working.
|
||||
*/
|
||||
export function registerOpenSpecDevPassthrough(openspec: Command, run: (args: string[]) => Promise<number> = runOpenspec): void {
|
||||
openspec.passThroughOptions();
|
||||
openspec
|
||||
.command("dev", { isDefault: true, hidden: true })
|
||||
.argument("[args...]", "Arguments for OpenSpec.dev's openspec command, exactly as it takes them")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.helpOption(false)
|
||||
.description("Any other word: OpenSpec.dev's own CLI (init, list, validate, archive, show, ...).")
|
||||
.action(async (args: string[]) => {
|
||||
process.exitCode = await run(args.length ? args : ["--help"]);
|
||||
});
|
||||
}
|
||||
87
packages/cli/src/umbrella.test.ts
Normal file
87
packages/cli/src/umbrella.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { meetsOpenmcpNode, openmcpNodeMessage, registerOpenMcpCommands } from "./openmcp.js";
|
||||
import { openspecBin, registerOpenSpecDevPassthrough } from "./openspec-dev.js";
|
||||
import { logicsrcMcpBin, registerMcpCommands } from "./mcp.js";
|
||||
|
||||
/** A program shaped like the real one: positional options on, no process.exit. */
|
||||
function program(): Command {
|
||||
const p = new Command();
|
||||
p.name("logicsrc").enablePositionalOptions().exitOverride();
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("logicsrc openmcp", () => {
|
||||
it("hands every argument, flags included, to openmcp's own main", async () => {
|
||||
const seen: string[][] = [];
|
||||
const p = program();
|
||||
registerOpenMcpCommands(p, async () => async (argv) => {
|
||||
seen.push(argv);
|
||||
return 0;
|
||||
});
|
||||
await p.parseAsync(["node", "logicsrc", "openmcp", "relays", "--online", "--catalog", "https://x.example", "--json"]);
|
||||
expect(seen).toEqual([["relays", "--online", "--catalog", "https://x.example", "--json"]]);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for help when given nothing, and passes the exit code back", async () => {
|
||||
const seen: string[][] = [];
|
||||
const p = program();
|
||||
registerOpenMcpCommands(p, async () => async (argv) => {
|
||||
seen.push(argv);
|
||||
return 3;
|
||||
});
|
||||
await p.parseAsync(["node", "logicsrc", "openmcp"]);
|
||||
expect(seen).toEqual([["help"]]);
|
||||
expect(process.exitCode).toBe(3);
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it("knows the catalog's Node floor and offers the standalone installer below it", () => {
|
||||
expect(meetsOpenmcpNode("v24.0.0")).toBe(true);
|
||||
expect(meetsOpenmcpNode("v22.18.0")).toBe(false);
|
||||
expect(openmcpNodeMessage("v22.18.0")).toContain("curl -fsSL https://openmcp.logicsrc.com/install.sh | sh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logicsrc openspec", () => {
|
||||
function group(run: (args: string[]) => Promise<number>): { p: Command; own: string[] } {
|
||||
const p = program();
|
||||
const own: string[] = [];
|
||||
const openspec = p.command("openspec");
|
||||
openspec.command("import").argument("[root]").action((root: string | undefined) => void own.push(`import ${root ?? ""}`.trim()));
|
||||
registerOpenSpecDevPassthrough(openspec, run);
|
||||
return { p, own };
|
||||
}
|
||||
|
||||
it("keeps its own words", async () => {
|
||||
const seen: string[][] = [];
|
||||
const { p, own } = group(async (args) => (seen.push(args), 0));
|
||||
await p.parseAsync(["node", "logicsrc", "openspec", "import", "specs"]);
|
||||
expect(own).toEqual(["import specs"]);
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends any other word to OpenSpec.dev's CLI with its flags intact", async () => {
|
||||
const seen: string[][] = [];
|
||||
const { p } = group(async (args) => (seen.push(args), 0));
|
||||
await p.parseAsync(["node", "logicsrc", "openspec", "validate", "--all", "--strict"]);
|
||||
await p.parseAsync(["node", "logicsrc", "openspec", "init"]);
|
||||
expect(seen).toEqual([["validate", "--all", "--strict"], ["init"]]);
|
||||
});
|
||||
|
||||
it("finds the bin beside dist/ from the package's only export", () => {
|
||||
expect(openspecBin(() => "/node_modules/@fission-ai/openspec/dist/index.js")).toBe("/node_modules/@fission-ai/openspec/bin/openspec.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logicsrc mcp", () => {
|
||||
it("runs the server entry, which is also its bin", async () => {
|
||||
const seen: string[][] = [];
|
||||
const p = program();
|
||||
registerMcpCommands(p, async (args) => (seen.push(args), 0));
|
||||
await p.parseAsync(["node", "logicsrc", "mcp"]);
|
||||
expect(seen).toEqual([[]]);
|
||||
expect(logicsrcMcpBin(() => "/x/dist/index.js")).toBe("/x/dist/index.js");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue