diff --git a/AGENTS.md b/AGENTS.md index 8f4acb7..221520f 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. -- `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`. diff --git a/README.md b/README.md index 071f267..6b2c2cd 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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. diff --git a/docs/specification.md b/docs/specification.md index 72bce3a..ed3e654 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -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 @@ -72,11 +73,18 @@ failed create are removed; a destination that existed before the invocation is never removed. `connect ` 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 ]` 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 ` attaches an exact, clean, fully valid Git root with no symlink components in place and never copies, moves, or deletes it. @@ -181,9 +189,9 @@ managed tree name; an explicit validated `OWNER/REPO` may override it. It runs one `gh repo create --private --source --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. diff --git a/package.json b/package.json index a10ddef..a751d07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/api.ts b/src/cli/api.ts index 651c214..4ec642d 100644 --- a/src/cli/api.ts +++ b/src/cli/api.ts @@ -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, @@ -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`); @@ -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 ", "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.") diff --git a/src/core/connections.ts b/src/core/connections.ts index 5dd38c9..852226e 100644 --- a/src/core/connections.ts +++ b/src/core/connections.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { chmodSync, existsSync, @@ -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"; @@ -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)) { @@ -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 }; +} diff --git a/src/core/publish.ts b/src/core/publish.ts index 7daecb7..fcce0d6 100644 --- a/src/core/publish.ts +++ b/src/core/publish.ts @@ -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, diff --git a/src/schemas.ts b/src/schemas.ts index 2600566..211f7e1 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -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", diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5297328..6c5c837 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -125,6 +125,7 @@ describe("built CLI", () => { "cleanup", "connect", "create", + "disconnect", "finish-write", "install", "list", diff --git a/tests/core.test.ts b/tests/core.test.ts index a8bf4f2..cad58d4 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -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", () => { diff --git a/tests/operations.test.ts b/tests/operations.test.ts index 5e737ed..78e9640 100644 --- a/tests/operations.test.ts +++ b/tests/operations.test.ts @@ -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"; @@ -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( @@ -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", () => { @@ -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, + ); +});