diff --git a/AGENTS.md b/AGENTS.md index 31063be..04130e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Distribution is npm only: the package exposes a `bin`, and `postinstall` runs `c - Host Git and GitHub CLI tools own private-repository authentication. - Zod schemas are the source of truth for public wire contracts. -- Every CLI subcommand prints exactly one line of JSON to stdout and nothing to stderr; human-readable output belongs in `scripts/postinstall.mjs`. +- `create`, `connect`, `list`, `resolve`, `publish`, `read`, and `verify` default to human-readable text and print one line of JSON to stdout only with `--json`; their text-mode failures print a sanitized line to stderr. `sync`, `prepare-write`, `finish-write`, and `install` always print exactly one line of JSON to stdout and nothing to stderr. Skills pass `--json` so their parsing is unchanged. - Use `unknown` plus narrowing; avoid `any`, enums, and unjustified type assertions. - Keep public functions explicitly typed and use `import type`. - Preserve path-containment and symlink fail-closed behavior. diff --git a/README.md b/README.md index 4284616..f5cdf80 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ An identical connection is idempotent. An explicit connect automatically switches the project. GitHub checkouts use the repository's lowercase name in the same flat managed namespace as created trees. -`context-tree list` reports valid, clean managed trees as -`{ schemaVersion: 1, trees: [{ name, tree }] }`; a missing managed directory -is an empty list. +`context-tree list` reports valid, clean managed trees; `context-tree list --json` +returns them as `{ schemaVersion: 1, trees: [{ name, tree }] }`, and a missing +managed directory is an empty list. ### Read @@ -190,7 +190,21 @@ orchestrates the five concrete workflows. `install` is the distribution entry point, run for you by `npm install`. `resolve`, `sync`, `prepare-write`, `finish-write`, and `verify` are plumbing or diagnostic commands rather than separate user intentions; `list` backs setup's connect-target discovery. -All machine-readable responses use strict schema version `1`. + +### Output + +`create`, `connect`, `list`, `resolve`, `publish`, `read`, and `verify` print +human-readable text by default and accept `--json` to emit their strict schema +version `1` payload for scripts and agents; in text mode a failure prints a +sanitized message to stderr with a non-zero exit code. The six skills always +pass `--json`. `sync`, `prepare-write`, `finish-write`, and `install` are +low-level plumbing and always emit that JSON (with the error envelope on stdout). +`--help` and `--version` are always plain text. + +```bash +context-tree verify # human-readable report +context-tree verify --json # { "ok": true, "schemaVersion": 1, ... } +``` `verify` is intended for CI and diagnostics. Normal skills invoke it only after an operation reports invalid tree content. diff --git a/scripts/package-e2e.mjs b/scripts/package-e2e.mjs index ad64dc2..b61c3d4 100644 --- a/scripts/package-e2e.mjs +++ b/scripts/package-e2e.mjs @@ -166,7 +166,7 @@ try { assert.equal(version.status, 0); assert.equal(version.stdout, `${manifest.version}\n`); - const created = runCli(cliPath, consumerRoot, ["create", "--project-path", "."]); + const created = runCli(cliPath, consumerRoot, ["create", "--project-path", ".", "--json"]); assert.equal(created.status, 0); const createdResult = parseOneLineJson(created.stdout); const treePath = createdResult.treePath; @@ -190,7 +190,7 @@ try { /branches: \["trunk"\]/u, ); - const resolved = runCli(cliPath, consumerRoot, ["resolve"]); + const resolved = runCli(cliPath, consumerRoot, ["resolve", "--json"]); assert.equal(resolved.status, 0); assert.equal(parseOneLineJson(resolved.stdout).tree.path, treePath); @@ -205,16 +205,16 @@ try { ); requirePackagedFile(consumerRoot, ".codex/skills/context-tree-write/SKILL.md"); - const validVerify = runCli(cliPath, consumerRoot, ["verify", "--tree-path", treePath]); + const validVerify = runCli(cliPath, consumerRoot, ["verify", "--tree-path", treePath, "--json"]); assert.equal(validVerify.status, 0); assert.equal(parseOneLineJson(validVerify.stdout).ok, true); - const read = runCli(cliPath, consumerRoot, ["read", "--tree-path", treePath]); + const read = runCli(cliPath, consumerRoot, ["read", "--tree-path", treePath, "--json"]); assert.equal(read.status, 0); assert.equal(parseOneLineJson(read.stdout).target, "."); rmSync(join(treePath, "NODE.md")); - const invalidVerify = runCli(cliPath, consumerRoot, ["verify", "--tree-path", treePath]); + const invalidVerify = runCli(cliPath, consumerRoot, ["verify", "--tree-path", treePath, "--json"]); assert.equal(invalidVerify.status, 1); assert.equal(parseOneLineJson(invalidVerify.stdout).ok, false); } finally { diff --git a/skills/context-tree-connect/SKILL.md b/skills/context-tree-connect/SKILL.md index b2392cc..f5ef17b 100644 --- a/skills/context-tree-connect/SKILL.md +++ b/skills/context-tree-connect/SKILL.md @@ -15,9 +15,9 @@ If `context-tree` is not found, stop and ask the user to run Connect exactly one target supplied by the user: - A managed tree name or GitHub `OWNER/REPO`: - `context-tree connect ""`. + `context-tree connect "" --json`. - An exact path to an existing Context Tree checkout: - `context-tree connect --tree-path ""`. + `context-tree connect --tree-path "" --json`. That checkout is attached where it already lives and is never copied, moved, or deleted. diff --git a/skills/context-tree-create/SKILL.md b/skills/context-tree-create/SKILL.md index 79b694f..3aeb112 100644 --- a/skills/context-tree-create/SKILL.md +++ b/skills/context-tree-create/SKILL.md @@ -12,7 +12,7 @@ metadata: If `context-tree` is not found, stop and ask the user to run `npm install --global @first-tree-ai/context-tree`. -Run `context-tree create`. Report whether the managed tree was created or +Run `context-tree create --json`. Report whether the managed tree was created or already existed, together with its name, path, and exact commit SHA. The managed name is derived from the project directory's name. If that name is @@ -25,7 +25,7 @@ sessions and other agents find it without any host-specific setup. The result's `pointer` field reports `written`, `updated`, or `skipped`; when it is not `skipped`, tell the user that `AGENTS.md` in their project changed. -After the tree is created or reused, run `context-tree resolve`. When the tree is +After the tree is created or reused, run `context-tree resolve --json`. When the tree is local, ask the user whether to publish it as a private GitHub repository. An explicit prior request to publish counts as confirmation; otherwise a "no" leaves the tree local, and a "yes" delegates to `$context-tree-publish`. Never diff --git a/skills/context-tree-publish/SKILL.md b/skills/context-tree-publish/SKILL.md index 0d27c11..c49c08e 100644 --- a/skills/context-tree-publish/SKILL.md +++ b/skills/context-tree-publish/SKILL.md @@ -12,7 +12,7 @@ metadata: If `context-tree` is not found, stop and ask the user to run `npm install --global @first-tree-ai/context-tree`. -Run `context-tree publish`. When the user explicitly supplies an alternative, +Run `context-tree publish --json`. When the user explicitly supplies an alternative, append the validated `OWNER/REPO` argument. Never accept a repository URL. Publication creates one new private repository, and the local connection update diff --git a/skills/context-tree-read/SKILL.md b/skills/context-tree-read/SKILL.md index 78835c0..dbca8b0 100644 --- a/skills/context-tree-read/SKILL.md +++ b/skills/context-tree-read/SKILL.md @@ -15,7 +15,7 @@ run `npm install --global @first-tree-ai/context-tree`. If it reports run `sync` again once. Use the returned `tree.path` for narrow, task-relevant reads with -`context-tree read [path] --tree-path ""`. Start at the root index, +`context-tree read [path] --tree-path "" --json`. Start at the root index, then open only the immediate children that bear on the task. Do not scan the whole tree. diff --git a/skills/context-tree-setup/SKILL.md b/skills/context-tree-setup/SKILL.md index 164bef0..bd775d4 100644 --- a/skills/context-tree-setup/SKILL.md +++ b/skills/context-tree-setup/SKILL.md @@ -12,14 +12,14 @@ metadata: If `context-tree` is not found, stop and ask the user to run `npm install --global @first-tree-ai/context-tree`. -Run `context-tree resolve`. If it succeeds, report whether the tree is local or +Run `context-tree resolve --json`. If it succeeds, report whether the tree is local or GitHub-backed, with its canonical path, and stop; the project is already set up. If `resolve` reports `NO_CONNECTION`, ask the user whether to create a new Context Tree or connect an existing one: - To create, delegate to `$context-tree-create`. -- To connect, run `context-tree list` and offer every listed managed name, a +- To connect, run `context-tree list --json` and offer every listed managed name, a GitHub `OWNER/REPO`, and an exact disk path. Delegate the chosen target to `$context-tree-connect`, which owns the rules for accepting it. diff --git a/src/cli/api.ts b/src/cli/api.ts index e50b6bd..5796d56 100644 --- a/src/cli/api.ts +++ b/src/cli/api.ts @@ -13,21 +13,45 @@ import { syncProject } from "../core/sync.js"; import { verifyTree } from "../core/verify.js"; import { finishContextWrite, prepareContextWrite } from "../core/write.js"; import { CLI_ERROR_CODES, type ContextTreeCliErrorEnvelope, SCHEMA_VERSION, skillHostSchema } from "../schemas.js"; +import { + formatConnect, + formatCreate, + formatList, + formatPublish, + formatRead, + formatResolve, + formatVerify, +} from "./format.js"; type ContextTreeCliIo = { cwd: () => string; stdout: (value: string) => void; + stderr?: (value: string) => void; }; const defaultIo: ContextTreeCliIo = { cwd: () => process.cwd(), + stderr: (value) => process.stderr.write(value), stdout: (value) => process.stdout.write(value), }; +/** Commands that default to human-readable text and accept --json to restore JSON. */ +const TEXT_DEFAULT_COMMANDS = new Set(["create", "connect", "list", "resolve", "publish", "read", "verify"]); + function line(io: ContextTreeCliIo, value: string): void { io.stdout(`${value}\n`); } +function errline(io: ContextTreeCliIo, value: string): void { + (io.stderr ?? ((text) => process.stderr.write(text)))(`${value}\n`); +} + +function emit(io: ContextTreeCliIo, json: boolean, result: T, format: (value: T) => string): void { + line(io, json ? JSON.stringify(result) : format(result)); +} + +const jsonOption = ["--json", "print machine-readable JSON (schema version 1) instead of text"] as const; + function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { const program = new Command() .name("context-tree") @@ -41,8 +65,9 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { .command("create") .description("Create and connect one uniquely named managed Context Tree for the current project.") .option("--project-path ", "project directory", ".") - .action((options: { projectPath: string }) => { - line(io, JSON.stringify(createProject(resolve(io.cwd(), options.projectPath)))); + .option(...jsonOption) + .action((options: { json: boolean; projectPath: string }) => { + emit(io, options.json, createProject(resolve(io.cwd(), options.projectPath)), formatCreate); }); program @@ -51,17 +76,23 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { .argument("[name-or-repository]", "managed tree name or GitHub OWNER/REPO") .option("--project-path ", "project directory", ".") .option("--tree-path ", "exact Context Tree Git root to connect in place") - .action((target: string | undefined, options: { projectPath: string; treePath?: string }) => { + .option(...jsonOption) + .action((target: string | undefined, options: { json: boolean; projectPath: string; treePath?: string }) => { const projectPath = resolve(io.cwd(), options.projectPath); if (target !== undefined && options.treePath !== undefined) { throw new Error("Connect requires exactly one of a name/repository or --tree-path."); } if (target !== undefined) { - line(io, JSON.stringify(connectProject({ projectPath, target }))); + emit(io, options.json, connectProject({ projectPath, target }), formatConnect); return; } if (options.treePath !== undefined) { - line(io, JSON.stringify(connectProject({ projectPath, treePath: resolve(io.cwd(), options.treePath) }))); + emit( + io, + options.json, + connectProject({ projectPath, treePath: resolve(io.cwd(), options.treePath) }), + formatConnect, + ); return; } throw new Error("Connect requires a managed tree name, GitHub OWNER/REPO, or --tree-path."); @@ -70,16 +101,18 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { program .command("list") .description("List valid clean managed Context Trees.") - .action(() => { - line(io, JSON.stringify(listManagedTrees())); + .option(...jsonOption) + .action((options: { json: boolean }) => { + emit(io, options.json, listManagedTrees(), formatList); }); program .command("resolve") .description("Resolve the connected Context Tree for a project.") .option("--project-path ", "project directory", ".") - .action((options: { projectPath: string }) => { - line(io, JSON.stringify(resolveConnection(resolve(io.cwd(), options.projectPath)))); + .option(...jsonOption) + .action((options: { json: boolean; projectPath: string }) => { + emit(io, options.json, resolveConnection(resolve(io.cwd(), options.projectPath)), formatResolve); }); program @@ -122,8 +155,9 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { .description("Publish the local tree as a new private GitHub repository.") .argument("[repository]", "GitHub OWNER/REPO override; defaults to the authenticated account and tree name") .option("--project-path ", "project directory", ".") - .action((repository: string | undefined, options: { projectPath: string }) => { - line(io, JSON.stringify(publishProject(resolve(io.cwd(), options.projectPath), { repository }))); + .option(...jsonOption) + .action((repository: string | undefined, options: { json: boolean; projectPath: string }) => { + emit(io, options.json, publishProject(resolve(io.cwd(), options.projectPath), { repository }), formatPublish); }); program @@ -131,7 +165,8 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { .description("Read an indexed Context Tree directory or Markdown leaf.") .argument("[path]", "tree-relative path", ".") .option("--tree-path ", "Context Tree root", ".") - .action((path: string, options: { treePath: string }) => { + .option(...jsonOption) + .action((path: string, options: { json: boolean; treePath: string }) => { const treePath = resolve(io.cwd(), options.treePath); if (!verifyTree(treePath).ok) { throw new ContextTreeError( @@ -139,16 +174,17 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { `Refusing to read an invalid Context Tree; run context-tree verify --tree-path ${treePath}.`, ); } - line(io, JSON.stringify(readTree(treePath, path))); + emit(io, options.json, readTree(treePath, path), formatRead); }); program .command("verify") .description("Validate Context Tree structure and safety.") .option("--tree-path ", "Context Tree root", ".") - .action((options: { treePath: string }) => { + .option(...jsonOption) + .action((options: { json: boolean; treePath: string }) => { const result = verifyTree(resolve(io.cwd(), options.treePath)); - line(io, JSON.stringify(result)); + emit(io, options.json, result, formatVerify); if (!result.ok) process.exitCode = 1; }); @@ -179,13 +215,24 @@ export async function runContextTreeCli( if (error instanceof CommanderError && error.exitCode === 0) return 0; const code = error instanceof ContextTreeError ? error.code : CLI_ERROR_CODES.failed; const message = sanitizeCommandOutput(error instanceof Error ? error.message : String(error)); - const envelope: ContextTreeCliErrorEnvelope = { - error: { code, message }, - ok: false, - schemaVersion: SCHEMA_VERSION, - }; - line(io, JSON.stringify(envelope)); + if (usesTextErrors(argv)) { + errline(io, `context-tree: ${message}`); + } else { + const envelope: ContextTreeCliErrorEnvelope = { + error: { code, message }, + ok: false, + schemaVersion: SCHEMA_VERSION, + }; + line(io, JSON.stringify(envelope)); + } process.exitCode = 1; return 1; } } + +/** A text-default command failing without --json reports a human-readable line on stderr. */ +function usesTextErrors(argv: string[]): boolean { + if (argv.includes("--json")) return false; + const subcommand = argv.slice(2).find((token) => !token.startsWith("-")); + return subcommand !== undefined && TEXT_DEFAULT_COMMANDS.has(subcommand); +} diff --git a/src/cli/format.ts b/src/cli/format.ts new file mode 100644 index 0000000..7362bbd --- /dev/null +++ b/src/cli/format.ts @@ -0,0 +1,95 @@ +import type { + ConnectProjectResult, + ContextTreeConnectionResult, + ContextTreePublishResult, + ContextTreeReadResult, + ContextTreeState, + CreateProjectResult, + ManagedTreeListingResult, + VerifyTreeReport, +} from "../schemas.js"; + +const POINTER_NOTE: Record = { + skipped: "left unchanged", + updated: "updated", + written: "written", +}; + +function treeLines(tree: ContextTreeState, indent = " "): string[] { + const lines = [`${indent}Path: ${tree.path}`]; + if (tree.kind === "github") lines.push(`${indent}Repository: ${tree.repository}`); + return lines; +} + +export function formatCreate(result: CreateProjectResult): string { + const verb = result.created ? "Created" : "Reused"; + return [ + `${verb} managed Context Tree "${result.title}".`, + ` Path: ${result.treePath}`, + ` Branch: ${result.branch}`, + ` Commit: ${result.commitSha}`, + ` AGENTS.md: ${POINTER_NOTE[result.pointer]}`, + ].join("\n"); +} + +export function formatConnect(result: ConnectProjectResult): string { + return [ + `Connected ${result.tree.kind} Context Tree.`, + ...treeLines(result.tree), + ` AGENTS.md: ${POINTER_NOTE[result.pointer]}`, + ].join("\n"); +} + +export function formatResolve(result: ContextTreeConnectionResult): string { + return [`Connected ${result.tree.kind} Context Tree.`, ...treeLines(result.tree)].join("\n"); +} + +export function formatList(result: ManagedTreeListingResult): string { + if (result.trees.length === 0) return "No managed Context Trees."; + const count = result.trees.length; + const lines = [`${count} managed Context Tree${count === 1 ? "" : "s"}:`]; + for (const entry of result.trees) lines.push(` ${entry.name} ${entry.tree.kind} ${entry.tree.path}`); + return lines.join("\n"); +} + +export function formatPublish(result: ContextTreePublishResult): string { + return [ + `Published Context Tree to ${result.repository}.`, + ` URL: ${result.url}`, + ` Branch: ${result.branch}`, + ` Commit: ${result.sha}`, + ].join("\n"); +} + +export function formatRead(result: ContextTreeReadResult): string { + const title = result.node.frontmatter.title; + const lines: string[] = []; + if (typeof title === "string" && title.trim().length > 0) lines.push(title.trim()); + lines.push(`Path: ${result.node.path} (${result.node.kind}, ${result.node.contentClass})`); + lines.push(`Root: ${result.root}`); + if (result.node.body.trim().length > 0) { + lines.push("", result.node.body.trimEnd()); + } + if (result.children.length > 0) { + lines.push("", "Children:"); + for (const child of result.children) { + const description = child.description ? ` — ${child.description}` : ""; + lines.push(` ${child.title}${description} [${child.path}]`); + } + } + return lines.join("\n"); +} + +export function formatVerify(report: VerifyTreeReport): string { + const counts = report.scannedByContentClass; + const lines = [ + report.ok ? "Context Tree OK." : "Context Tree INVALID.", + ` Root: ${report.root}`, + ` Scanned: normal=${counts.normal} member=${counts.member} repo-infra=${counts["repo-infra"]}`, + ]; + if (report.findings.length > 0) { + lines.push(" Findings:"); + for (const finding of report.findings) lines.push(` ${finding.code} ${finding.path}: ${finding.message}`); + } + return lines.join("\n"); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index d26424c..fcc6a5f 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -35,6 +35,13 @@ function expectCliError(result: CliResult, code: string): ReturnType { const root = workspace(); const project = join(root, "My Service!"); mkdirSync(project, { recursive: true }); - const created = JSON.parse(cli(project, ["create"], undefined, root).stdout) as CreateResult; + const created = JSON.parse(cli(project, ["create", "--json"], undefined, root).stdout) as CreateResult; expect(created.created).toBe(true); expect(created.title).toBe("my-service-context-tree"); expect(created.treePath).toBe(join(realpathSync(root), ".context-tree", "trees", "my-service-context-tree")); @@ -142,13 +149,15 @@ describe("built CLI", () => { expect(created.commitSha).toMatch(/^[0-9a-f]{40}$/u); expect(existsSync(join(created.treePath, "NODE.md"))).toBe(true); - const resolved = JSON.parse(cli(project, ["resolve"], undefined, root).stdout); + const resolved = JSON.parse(cli(project, ["resolve", "--json"], undefined, root).stdout); expect(resolved).toEqual({ schemaVersion: 1, tree: { kind: "local", path: created.treePath } }); - const verify = JSON.parse(cli(project, ["verify", "--tree-path", created.treePath], undefined, root).stdout); + const verify = JSON.parse( + cli(project, ["verify", "--tree-path", created.treePath, "--json"], undefined, root).stdout, + ); expect(verifyTreeReportSchema.parse(verify)).toMatchObject({ ok: true }); - const read = JSON.parse(cli(project, ["read", "--tree-path", created.treePath], undefined, root).stdout); + const read = JSON.parse(cli(project, ["read", "--tree-path", created.treePath, "--json"], undefined, root).stdout); expect(contextTreeReadResultSchema.parse(read)).toMatchObject({ target: "." }); }); @@ -156,7 +165,7 @@ describe("built CLI", () => { const root = workspace(); const project = join(root, "service"); mkdirSync(project); - const created = JSON.parse(cli(project, ["create"], undefined, root).stdout) as CreateResult & { + const created = JSON.parse(cli(project, ["create", "--json"], undefined, root).stdout) as CreateResult & { pointer: string; }; expect(created.pointer).toBe("written"); @@ -166,7 +175,9 @@ describe("built CLI", () => { expect(instructions).toContain(""); // Reconnecting the same tree rewrites the single block rather than appending another. - const reconnected = JSON.parse(cli(project, ["connect", "service-context-tree"], undefined, root).stdout) as { + const reconnected = JSON.parse( + cli(project, ["connect", "service-context-tree", "--json"], undefined, root).stdout, + ) as { pointer: string; }; expect(reconnected.pointer).toBe("skipped"); @@ -241,7 +252,7 @@ describe("built CLI", () => { const nested = join(project, "deep", "nested"); mkdirSync(nested, { recursive: true }); - expect(JSON.parse(cli(nested, ["resolve"], undefined, root).stdout)).toEqual({ + expect(JSON.parse(cli(nested, ["resolve", "--json"], undefined, root).stdout)).toEqual({ schemaVersion: 1, tree: { kind: "local", path: created.treePath }, }); @@ -256,15 +267,15 @@ describe("built CLI", () => { const clone = join(root, "clone"); git(root, ["clone", "--quiet", project, clone], root); - expectCliError(cli(clone, ["resolve"], undefined, root), "NO_CONNECTION"); + expectCliError(cli(clone, ["resolve", "--json"], undefined, root), "NO_CONNECTION"); writeFileSync(join(project, "file.txt"), "content\n"); git(project, ["add", "file.txt"], root); git(project, ["commit", "--quiet", "-m", "commit"], root); const worktree = join(root, "worktree"); git(project, ["worktree", "add", "--quiet", worktree], root); - expectCliError(cli(worktree, ["resolve"], undefined, root), "NO_CONNECTION"); - expect(JSON.parse(cli(project, ["resolve"], undefined, root).stdout)).toEqual({ + expectCliError(cli(worktree, ["resolve", "--json"], undefined, root), "NO_CONNECTION"); + expect(JSON.parse(cli(project, ["resolve", "--json"], undefined, root).stdout)).toEqual({ schemaVersion: 1, tree: { kind: "local", path: created.treePath }, }); @@ -277,7 +288,7 @@ describe("built CLI", () => { create(root, project); const nested = join(project, "sub", "dir"); mkdirSync(nested, { recursive: true }); - expectCliError(cli(nested, ["resolve"], undefined, root), "NO_CONNECTION"); + expectCliError(cli(nested, ["resolve", "--json"], undefined, root), "NO_CONNECTION"); }); it("connects a second project by exact managed name", () => { @@ -287,13 +298,13 @@ describe("built CLI", () => { const created = create(root, first); const second = join(root, "second"); mkdirSync(second); - const connected = JSON.parse(cli(second, ["connect", "first-context-tree"], undefined, root).stdout); + const connected = JSON.parse(cli(second, ["connect", "first-context-tree", "--json"], undefined, root).stdout); expect(connected).toEqual({ pointer: "written", schemaVersion: 1, tree: { kind: "local", path: created.treePath }, }); - expect(JSON.parse(cli(second, ["resolve"], undefined, root).stdout)).toEqual({ + expect(JSON.parse(cli(second, ["resolve", "--json"], undefined, root).stdout)).toEqual({ schemaVersion: 1, tree: { kind: "local", path: created.treePath }, }); @@ -304,7 +315,7 @@ describe("built CLI", () => { const project = join(root, "service"); mkdirSync(project); const created = create(root, project); - const listing = JSON.parse(cli(project, ["list"], undefined, root).stdout); + const listing = JSON.parse(cli(project, ["list", "--json"], undefined, root).stdout); expect(managedTreeListingResultSchema.parse(listing)).toEqual({ schemaVersion: 1, trees: [{ name: "service-context-tree", tree: { kind: "local", path: created.treePath } }], @@ -318,9 +329,9 @@ describe("built CLI", () => { mkdirSync(first); mkdirSync(second); const tree = create(root, first).treePath; - const connected = JSON.parse(cli(second, ["connect", "--tree-path", tree], undefined, root).stdout); + const connected = JSON.parse(cli(second, ["connect", "--tree-path", tree, "--json"], undefined, root).stdout); expect(connected).toEqual({ pointer: "written", schemaVersion: 1, tree: { kind: "local", path: tree } }); - expect(JSON.parse(cli(second, ["resolve"], undefined, root).stdout).tree.path).toBe(tree); + expect(JSON.parse(cli(second, ["resolve", "--json"], undefined, root).stdout).tree.path).toBe(tree); }); it("rejects ambiguous disk-path connect syntax", () => { @@ -329,7 +340,7 @@ describe("built CLI", () => { mkdirSync(project); const tree = create(root, project).treePath; expectCliError( - cli(project, ["connect", "service-context-tree", "--tree-path", tree], undefined, root), + cli(project, ["connect", "service-context-tree", "--tree-path", tree, "--json"], undefined, root), "CONTEXT_TREE_FAILED", ); }); @@ -343,8 +354,8 @@ describe("built CLI", () => { const firstTree = create(root, first).treePath; const secondTree = create(root, second).treePath; - expect(JSON.parse(cli(first, ["resolve"], undefined, root).stdout).tree.path).toBe(firstTree); - const replaced = JSON.parse(cli(first, ["connect", "second-context-tree"], undefined, root).stdout); + expect(JSON.parse(cli(first, ["resolve", "--json"], undefined, root).stdout).tree.path).toBe(firstTree); + const replaced = JSON.parse(cli(first, ["connect", "second-context-tree", "--json"], undefined, root).stdout); expect(replaced.tree.path).toBe(secondTree); }); @@ -353,11 +364,11 @@ describe("built CLI", () => { const project = join(root, "service"); mkdirSync(project); create(root, project); - expectCliError(cli(project, ["connect"], undefined, root), "CONTEXT_TREE_FAILED"); - expectCliError(cli(project, ["connect", "../unsafe"], undefined, root), "CONTEXT_TREE_FAILED"); - expectCliError(cli(project, ["connect", "MissingName"], undefined, root), "CONTEXT_TREE_FAILED"); + expectCliError(cli(project, ["connect", "--json"], undefined, root), "CONTEXT_TREE_FAILED"); + expectCliError(cli(project, ["connect", "../unsafe", "--json"], undefined, root), "CONTEXT_TREE_FAILED"); + expectCliError(cli(project, ["connect", "MissingName", "--json"], undefined, root), "CONTEXT_TREE_FAILED"); expectCliError( - cli(project, ["connect", "service-context-tree", "--replace"], undefined, root), + cli(project, ["connect", "service-context-tree", "--replace", "--json"], undefined, root), "CONTEXT_TREE_FAILED", ); }); @@ -366,7 +377,7 @@ describe("built CLI", () => { const root = workspace(); const project = join(root, "service"); mkdirSync(project); - expectCliError(cli(project, ["resolve"], undefined, root), "NO_CONNECTION"); + expectCliError(cli(project, ["resolve", "--json"], undefined, root), "NO_CONNECTION"); expectCliError(cli(project, ["sync"], undefined, root), "NO_CONNECTION"); expectCliError(cli(project, ["prepare-write"], undefined, root), "NO_CONNECTION"); @@ -374,22 +385,22 @@ describe("built CLI", () => { const connectionPath = join(root, ".context-tree", "connections.json"); const connection = JSON.parse(readFileSync(connectionPath, "utf8")).connections[0]; writeFileSync(connectionPath, `${JSON.stringify({ connections: [connection, connection], schemaVersion: 1 })}\n`); - expectCliError(cli(project, ["resolve"], undefined, root), "CORRUPT_CONNECTION"); + expectCliError(cli(project, ["resolve", "--json"], undefined, root), "CORRUPT_CONNECTION"); writeFileSync(connectionPath, "{not json"); - expectCliError(cli(project, ["resolve"], undefined, root), "CORRUPT_CONNECTION"); + expectCliError(cli(project, ["resolve", "--json"], undefined, root), "CORRUPT_CONNECTION"); rmSync(connectionPath); - expect(cli(project, ["connect", "service-context-tree"], undefined, root).status).toBe(0); + expect(cli(project, ["connect", "service-context-tree", "--json"], undefined, root).status).toBe(0); // An uncommitted edit is the user's own work in progress, not a broken connection. writeFileSync(join(created.treePath, "draft.md"), '---\ntitle: "Draft"\n---\n\n# Draft\n'); - const dirty = expectCliError(cli(project, ["resolve"], undefined, root), "DIRTY_TREE"); + const dirty = expectCliError(cli(project, ["resolve", "--json"], undefined, root), "DIRTY_TREE"); expect(dirty.error.message).toContain("commit or discard"); rmSync(join(created.treePath, "draft.md")); renameSync(created.treePath, `${created.treePath}-moved`); - const stale = expectCliError(cli(project, ["resolve"], undefined, root), "STALE_CONNECTION"); + const stale = expectCliError(cli(project, ["resolve", "--json"], undefined, root), "STALE_CONNECTION"); expect(stale.error.message).toContain("context-tree connect"); }); @@ -433,12 +444,14 @@ describe("built CLI", () => { ); expect(finished).toMatchObject({ branch: "trunk", sha: expect.stringMatching(/^[0-9a-f]{40}$/u) }); expect(existsSync(prepared.worktreePath)).toBe(false); - expect(JSON.parse(cli(project, ["resolve"], undefined, root).stdout)).toEqual({ + expect(JSON.parse(cli(project, ["resolve", "--json"], undefined, root).stdout)).toEqual({ schemaVersion: 1, tree: { kind: "local", path: created.treePath }, }); - const verify = JSON.parse(cli(project, ["verify", "--tree-path", created.treePath], undefined, root).stdout); + const verify = JSON.parse( + cli(project, ["verify", "--tree-path", created.treePath, "--json"], undefined, root).stdout, + ); expect(verify).toMatchObject({ ok: true }); expect(existsSync(join(created.treePath, "members", "engineer", "memory.md"))).toBe(true); @@ -473,7 +486,12 @@ describe("built CLI", () => { mkdirSync(bin); writeFileSync(join(bin, "gh"), "#!/bin/sh\necho 'gh auth login required' >&2\nexit 1\n"); chmodSync(join(bin, "gh"), 0o755); - const publish = cli(project, ["publish"], { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, root); + const publish = cli( + project, + ["publish", "--json"], + { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + root, + ); expectCliError(publish, "GITHUB_AUTH"); }); @@ -492,9 +510,9 @@ describe("built CLI", () => { writeFileSync(join(created.treePath, "NODE.md"), '---\nschemaVersion: 1\ntitle: "Broken"\n---\n'); git(created.treePath, ["add", "NODE.md"], root); git(created.treePath, ["commit", "--quiet", "-m", "break"], root); - const read = cli(project, ["read", "--tree-path", created.treePath], undefined, root); + const read = cli(project, ["read", "--tree-path", created.treePath, "--json"], undefined, root); expect(expectCliError(read, "INVALID_TREE").error.message).toContain("context-tree verify"); - expectCliError(cli(project, ["connect", "service-context-tree"], undefined, root), "INVALID_TREE"); + expectCliError(cli(project, ["connect", "service-context-tree", "--json"], undefined, root), "INVALID_TREE"); }); it("refuses to create a second tree for an already connected project", () => { @@ -504,9 +522,41 @@ describe("built CLI", () => { mkdirSync(first); mkdirSync(second); const shared = create(root, first).treePath; - expect(cli(second, ["connect", "--tree-path", shared], undefined, root).status).toBe(0); - const failure = expectCliError(cli(second, ["create"], undefined, root), "CONTEXT_TREE_FAILED"); + expect(cli(second, ["connect", "--tree-path", shared, "--json"], undefined, root).status).toBe(0); + const failure = expectCliError(cli(second, ["create", "--json"], undefined, root), "CONTEXT_TREE_FAILED"); expect(failure.error.message).toContain("already connected"); - expect(JSON.parse(cli(second, ["resolve"], undefined, root).stdout).tree.path).toBe(shared); + expect(JSON.parse(cli(second, ["resolve", "--json"], undefined, root).stdout).tree.path).toBe(shared); + }); + + it("prints human-readable text by default for the inspection commands", () => { + const root = workspace(); + const project = join(root, "service"); + mkdirSync(project); + const created = create(root, project); + + const resolved = cli(project, ["resolve"], undefined, root); + expect(resolved.status).toBe(0); + expect(resolved.stderr).toBe(""); + expect(resolved.stdout).toContain("Connected local Context Tree."); + expect(resolved.stdout).toContain(created.treePath); + + const verify = cli(project, ["verify", "--tree-path", created.treePath], undefined, root); + expect(verify.stdout).toContain("Context Tree OK."); + expect(verify.stdout).not.toContain('"schemaVersion"'); + + const read = cli(project, ["read", "--tree-path", created.treePath], undefined, root); + expect(read.stdout).toContain("Path: ."); + expect(read.stdout).toContain(`Root: ${created.treePath}`); + + const listing = cli(project, ["list"], undefined, root); + expect(listing.stdout).toContain("managed Context Tree"); + expect(listing.stdout).toContain("service-context-tree"); + }); + + it("reports a text-mode failure on stderr with a non-zero exit code", () => { + const root = workspace(); + const project = join(root, "service"); + mkdirSync(project); + expectCliTextError(cli(project, ["resolve"], undefined, root), "No Context Tree connection exists"); }); });