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.
- `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`, `install`, and `uninstall` always print exactly one line of JSON to stdout and nothing to stderr. Skills pass `--json` so their parsing is unchanged.
- `create`, `connect`, `disconnect`, `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`, `install`, and `uninstall` always print exactly one line of JSON to stdout and nothing to stderr. Skills pass `--json` so their parsing is unchanged.
- Install and uninstall own exactly the `context-tree-` prefix in each host's skills directory; nothing else is ever replaced or removed.
- Use `unknown` plus narrowing; avoid `any`, enums, and unjustified type assertions.
- Keep public functions explicitly typed and use `import type`.
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ context-tree connect --tree-path /absolute/path/to/tree
Connecting switches the current project's tree. Run these commands from the
project directory, or add `--project-path /path/to/project`.

Stop using a tree without deleting it or its memory:

```bash
context-tree disconnect
```

## Cleanup and scheduling

Ask your agent to remove outdated clutter and consolidate duplicate context:
Expand Down Expand Up @@ -102,6 +108,7 @@ lists previous runs.
context-tree resolve # show this project's tree
context-tree read --tree-path /path/to/tree # browse its root index
context-tree verify --tree-path /path/to/tree # check its structure
context-tree disconnect # remove this project's connection
context-tree install # add skills for a new agent
context-tree install --project . # install skills for this project
context-tree uninstall # remove Context Tree skills
Expand All @@ -113,14 +120,14 @@ including synchronization, prepared writes, and JSON output.

## Working with OpenTag

Create or publish a tree as above, then choose it on each OpenTag Computer:
Each OpenTag Agent selects its own tree in **Agent settings → Context Tree**.
Enter a GitHub `OWNER/REPO` to connect an existing tree, create a new private
repository from the same panel, or disconnect to leave memory off. OpenTag
connects every visible and internal session for that Agent to the selection.
For a GitHub tree, GitHub authentication must work on the Agent's Computer.

```bash
opentag context-tree connect OWNER/REPO
# Or use a local tree:
opentag context-tree connect my-project-context-tree
```
The former `opentag context-tree connect` command is retired and now points to
Agent settings. Existing trees, connections, and memory are preserved; after
upgrading OpenTag, select the repository again for each Agent.

OpenTag connects each agent workspace to that tree when a session starts.
Use the same read, write, and cleanup prompts in those sessions. For a GitHub tree,
GitHub authentication must work on each Computer.
Use the same read, write, and cleanup prompts in OpenTag sessions.
28 changes: 18 additions & 10 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,14 @@ type Publish = {
repository: string; url: string; branch: string; sha: string;
schemaVersion: 1;
};
type Disconnect = { disconnected: boolean; schemaVersion: 1 };
```

Errors use `{ ok: false, error: { code, message }, schemaVersion: 1 }`.
Lifecycle-specific codes are `NO_CONNECTION`, `CORRUPT_CONNECTION`,
`STALE_CONNECTION`, `DIRTY_TREE`, `INVALID_TREE`, `WRITE_OUTDATED`,
`GITHUB_AUTH`, `REPOSITORY_EXISTS`, and `PUBLISH_INCOMPLETE`. Other failures
use `CONTEXT_TREE_FAILED`.
`GITHUB_AUTH`, `GITHUB_PERMISSION`, `REPOSITORY_EXISTS`, and
`PUBLISH_INCOMPLETE`. Other failures use `CONTEXT_TREE_FAILED`.

## Creation and connection

Expand All @@ -72,11 +73,18 @@ failed create are removed; a destination that existed before the invocation is
never removed.

`connect <name>` performs an exact managed-directory lookup. `connect
OWNER/REPO` reuses a matching checkout or clones it under the lowercased
repository name. Every selection is validated and safely classified as local
or GitHub state. Local-tree, repository, and unsafe-origin name collisions fail
before the project connection changes. Explicit connection switches are
automatic. Only a directory created by a failed clone is removed.
OWNER/REPO` reuses a checkout whose verified origin matches the full repository
identity or clones it into a managed name derived from `OWNER/REPO`, so equal
repository names under different owners never share a directory. Every
selection is validated and safely classified as local or GitHub state.
Repository and unsafe-origin name collisions fail before the project
connection changes. Explicit connection switches are automatic. Only a
directory created by a failed clone is removed. Authentication and permission
failures during the clone report `GITHUB_AUTH` and `GITHUB_PERMISSION`.

`disconnect [--project-path <path>]` removes only this project's stored
connection, preserving its tree and repository. It is idempotent, validates
neither the tree nor its contents, and reports `{ disconnected: boolean }`.

`connect --tree-path <path>` attaches an exact, clean, fully valid Git root
with no symlink components in place and never copies, moves, or deletes it.
Expand Down Expand Up @@ -181,9 +189,9 @@ managed tree name; an explicit validated `OWNER/REPO` may override it.
It runs one `gh repo create --private --source <tree> --remote origin --push`.

After success, the connection is atomically updated to GitHub state. Clear
authentication failures produce `GITHUB_AUTH`; clear name collisions produce
`REPOSITORY_EXISTS`; uncertain or partial outcomes produce
`PUBLISH_INCOMPLETE`. Publication does not inspect, adopt, repair, retry, or
authentication failures produce `GITHUB_AUTH`; clear permission denials produce
`GITHUB_PERMISSION`; clear name collisions produce `REPOSITORY_EXISTS`;
uncertain or partial outcomes produce `PUBLISH_INCOMPLETE`. Publication does not inspect, adopt, repair, retry, or
delete partial GitHub state. The GitHub operation and local connection update are not
atomic.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@first-tree-ai/context-tree",
"version": "0.1.13",
"version": "0.1.15",
"description": "Durable, structured project context for coding agents: a CLI plus framework-neutral skills.",
"type": "module",
"license": "Apache-2.0",
Expand Down
25 changes: 23 additions & 2 deletions src/cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
runCleanup,
scheduleCleanup,
} from "../core/cleanup/index.js";
import { connectProject, listManagedTrees, resolveConnection } from "../core/connections.js";
import { connectProject, disconnectProject, listManagedTrees, resolveConnection } from "../core/connections.js";
import { createProject } from "../core/create.js";
import {
type InstallSkillsOptions,
Expand Down Expand Up @@ -54,7 +54,17 @@ const defaultIo: ContextTreeCliIo = {
};

/** 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", "cleanup"]);
const TEXT_DEFAULT_COMMANDS = new Set([
"create",
"connect",
"disconnect",
"list",
"resolve",
"publish",
"read",
"verify",
"cleanup",
]);

function line(io: ContextTreeCliIo, value: string): void {
io.stdout(`${value}\n`);
Expand Down Expand Up @@ -116,6 +126,17 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command {
throw new Error("Connect requires a managed tree name, GitHub OWNER/REPO, or --tree-path.");
});

program
.command("disconnect")
.description("Remove this project's connection, preserving its Context Tree and repository.")
.option("--project-path <path>", "project directory", ".")
.option(...jsonOption)
.action((options: { json: boolean; projectPath: string }) => {
emit(io, options.json, disconnectProject(resolve(io.cwd(), options.projectPath)), (result) =>
result.disconnected ? "Context Tree disconnected." : "No Context Tree connection.",
);
});

program
.command("list")
.description("List valid clean managed Context Trees.")
Expand Down
36 changes: 34 additions & 2 deletions src/core/connections.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
Expand Down Expand Up @@ -28,7 +29,7 @@ import {
treeNameSchema,
} from "../schemas.js";
import { ContextTreeError } from "./internal/errors.js";
import { type CommandRunner, git, optionalGit } from "./internal/git.js";
import { CommandError, type CommandRunner, git, optionalGit } from "./internal/git.js";
import { canonicalGitHubRepositoryUrl, gitHubRepositoryFromOriginUrl } from "./internal/github-repository.js";
import { canonicalProjectRoot } from "./internal/project.js";
import { linkProjectInstructions } from "./internal/project-instructions.js";
Expand Down Expand Up @@ -289,7 +290,12 @@ export function connectProject(options: ConnectProjectOptions, runner?: CommandR
const repository = githubRepositoryIdentitySchema.parse(options.target);
const repositoryName = repository.split("/")[1];
if (repositoryName === undefined) throw new Error("Repository must be OWNER/REPO.");
const name = managedName(repositoryName.toLowerCase());
// Reuse published or legacy checkouts only after verifying their full origin identity.
for (const candidate of listManagedTrees(runner).trees) {
if (candidate.tree.kind === "github" && sameRepository(candidate.tree.repository, repository))
return connect(candidate.tree);
}
const name = managedName(`github-${createHash("sha256").update(repository.toLowerCase()).digest("hex")}`);
const destination = join(treesRoot, name);

if (existsSync(destination)) {
Expand All @@ -315,6 +321,32 @@ export function connectProject(options: ConnectProjectOptions, runner?: CommandR
return connect(tree);
} catch (error) {
rmSync(destination, { force: true, recursive: true });
if (
error instanceof CommandError &&
/gh auth login|not logged|authentication failed|http 401|bad credentials|could not read Username|terminal prompts disabled/iu.test(
error.stderr,
)
) {
throw new ContextTreeError(
CLI_ERROR_CODES.githubAuth,
"GitHub authentication failed; run gh auth login before connecting.",
);
}
if (error instanceof CommandError && /http 403|permission denied|not authorized/iu.test(error.stderr)) {
throw new ContextTreeError(CLI_ERROR_CODES.githubPermission, "GitHub denied access to this repository.");
}
throw error;
}
}

/** Remove only the project record, even if its tree is no longer usable. */
export function disconnectProject(
projectPath: string,
runner?: CommandRunner,
): { schemaVersion: 1; disconnected: boolean } {
const canonical = canonicalProjectRoot(projectPath, runner);
const stored = loadConnections(false);
const remaining = stored.connections.filter((connection) => connection.projectPath !== canonical);
if (remaining.length !== stored.connections.length) saveConnections({ ...stored, connections: remaining });
return { schemaVersion: SCHEMA_VERSION, disconnected: remaining.length !== stored.connections.length };
}
12 changes: 12 additions & 0 deletions src/core/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ function authenticatedAccount(runner?: CommandRunner): string {
}

function classifyCreationFailure(stderr: string): ContextTreeError {
if (/gh auth login|not logged|authentication failed|http 401|bad credentials/iu.test(stderr)) {
return new ContextTreeError(
CLI_ERROR_CODES.githubAuth,
"GitHub authentication failed; run gh auth login before publishing.",
);
}
if (/http 403|permission denied|not authorized|resource not accessible/iu.test(stderr)) {
return new ContextTreeError(
CLI_ERROR_CODES.githubPermission,
"GitHub denied permission to create this repository.",
);
}
if (/already exists/iu.test(stderr)) {
return new ContextTreeError(
CLI_ERROR_CODES.repositoryExists,
Expand Down
1 change: 1 addition & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const CLI_ERROR_CODES = {
dirtyTree: "DIRTY_TREE",
failed: "CONTEXT_TREE_FAILED",
githubAuth: "GITHUB_AUTH",
githubPermission: "GITHUB_PERMISSION",
invalidTree: "INVALID_TREE",
noConnection: "NO_CONNECTION",
publishIncomplete: "PUBLISH_INCOMPLETE",
Expand Down
1 change: 1 addition & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe("built CLI", () => {
"cleanup",
"connect",
"create",
"disconnect",
"finish-write",
"install",
"list",
Expand Down
12 changes: 6 additions & 6 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,18 +536,18 @@ describe("publish", () => {
);

it.each([
"gh auth login required",
"HTTP 403 permission denied",
"network unreachable",
"repository created but push failed",
])("surfaces ambiguous publication failure %j as PUBLISH_INCOMPLETE", (stderr) => {
["gh auth login required", "GITHUB_AUTH"],
["HTTP 403 permission denied", "GITHUB_PERMISSION"],
["network unreachable", "PUBLISH_INCOMPLETE"],
["repository created but push failed", "PUBLISH_INCOMPLETE"],
])("classifies publication failure %j as %s", (stderr, code) => {
const { project } = connectedProject();
const runner = scriptedRunner((command, args) => {
if (command === "gh" && args[0] === "repo") return { status: 1, stderr };
return undefined;
});
const failure = publishError(() => publishProject(project, { repository: "acme/context" }, runner));
expect(failure.code).toBe("PUBLISH_INCOMPLETE");
expect(failure.code).toBe(code);
});

it("refuses to publish an invalid tree", () => {
Expand Down
57 changes: 52 additions & 5 deletions tests/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import { tmpdir } from "node:os";
import { basename, join } from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { connectProject, listManagedTrees, managedTreesRoot, resolveConnection } from "../src/core/connections.js";
import {
connectProject,
disconnectProject,
listManagedTrees,
managedTreesRoot,
resolveConnection,
} from "../src/core/connections.js";
import { createProject } from "../src/core/create.js";
import { type CommandRunner, defaultRunner } from "../src/core/internal/git.js";
import { readTree } from "../src/core/read.js";
Expand Down Expand Up @@ -83,7 +89,8 @@ function githubRunner(remote: string, log: string[][] = []): CommandRunner {
return (command, args) => {
log.push([command, ...args]);
if (command === "git" && args.slice(-3).join(" ") === "remote get-url origin") {
return { status: 0, stderr: "", stdout: "https://github.com/acme/context.git\n" };
const actual = defaultRunner(command, args);
return actual.status === 0 ? { status: 0, stderr: "", stdout: "https://github.com/acme/context.git\n" } : actual;
}
if (command === "git" && args.includes("clone")) {
return defaultRunner(
Expand Down Expand Up @@ -355,13 +362,19 @@ describe("GitHub lifecycle", () => {
).toThrow(/does not belong/u);
});

it("fails repository-name collisions before switching the project connection", () => {
it("allocates a GitHub checkout without colliding with a local tree", () => {
const currentProject = project();
const local = createProject(currentProject);
const occupied = join(managedTreesRoot(), "context");
scaffoldTree({ name: "context", path: occupied });
expect(() => connectProject({ projectPath: currentProject, target: "acme/context" })).toThrow(/different tree/u);
expect(resolveConnection(currentProject).tree.path).toBe(local.treePath);
const { remote } = bareTree();
const runner = githubRunner(remote);
const connected = connectProject({ projectPath: currentProject, target: "acme/context" }, runner);
expect(connected.tree.path).not.toBe(occupied);
expect(existsSync(local.treePath)).toBe(true);
expect(connectProject({ projectPath: currentProject, target: "acme/context" }, runner).tree.path).toBe(
connected.tree.path,
);
});

it("rejects unsafe origins and symlinked managed names", () => {
Expand Down Expand Up @@ -446,3 +459,37 @@ describe.each(["local", "github"] as const)("%s cleanup concurrency", (kind) =>
expect(git(remote ?? treePath, ["rev-parse", "refs/heads/trunk"])).toBe(finished.sha);
});
});

it("disconnects idempotently without validating or deleting the tree", () => {
const currentProject = project();
const created = createProject(currentProject);
writeFileSync(join(created.treePath, "NODE.md"), "invalid");
expect(disconnectProject(currentProject)).toEqual({ schemaVersion: 1, disconnected: true });
expect(disconnectProject(currentProject)).toEqual({ schemaVersion: 1, disconnected: false });
expect(existsSync(created.treePath)).toBe(true);
expect(() => resolveConnection(currentProject)).toThrow(expect.objectContaining({ code: "NO_CONNECTION" }));
});

it("keeps equal repository names under different owners separate", () => {
const { remote } = bareTree();
const runner: CommandRunner = (command, args) => {
if (command === "git" && args.includes("clone")) {
const url = args[args.length - 2];
const destination = args[args.length - 1];
const result = defaultRunner(
command,
args.map((arg) => (arg === url ? remote : arg)),
);
if (result.status === 0 && destination && url)
defaultRunner("git", ["-C", destination, "remote", "set-url", "origin", url]);
return result;
}
return defaultRunner(command, args);
};
const first = connectProject({ projectPath: project("one"), target: "one/memory" }, runner);
const second = connectProject({ projectPath: project("two"), target: "two/memory" }, runner);
expect(first.tree.path).not.toBe(second.tree.path);
expect(connectProject({ projectPath: project("three"), target: "one/memory" }, runner).tree.path).toBe(
first.tree.path,
);
});
Loading