Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions scripts/package-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions skills/context-tree-connect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name-or-OWNER/REPO>"`.
`context-tree connect "<name-or-OWNER/REPO>" --json`.
- An exact path to an existing Context Tree checkout:
`context-tree connect --tree-path "<path>"`.
`context-tree connect --tree-path "<path>" --json`.
That checkout is attached where it already lives and is never copied, moved,
or deleted.

Expand Down
4 changes: 2 additions & 2 deletions skills/context-tree-create/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/context-tree-publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/context-tree-read/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<tree-path>"`. Start at the root index,
`context-tree read [path] --tree-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.

Expand Down
4 changes: 2 additions & 2 deletions skills/context-tree-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
89 changes: 68 additions & 21 deletions src/cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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")
Expand All @@ -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 <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
Expand All @@ -51,17 +76,23 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command {
.argument("[name-or-repository]", "managed tree name or GitHub OWNER/REPO")
.option("--project-path <path>", "project directory", ".")
.option("--tree-path <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.");
Expand All @@ -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 <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
Expand Down Expand Up @@ -122,33 +155,36 @@ 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 <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
.command("read")
.description("Read an indexed Context Tree directory or Markdown leaf.")
.argument("[path]", "tree-relative path", ".")
.option("--tree-path <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(
CLI_ERROR_CODES.invalidTree,
`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 <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;
});

Expand Down Expand Up @@ -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);
}
Loading
Loading