diff --git a/AGENTS.md b/AGENTS.md index 04130e0..d848049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ 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`, and `install` always print exactly one line of JSON to stdout and nothing to stderr. Skills pass `--json` so their parsing is unchanged. +- `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. +- 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`. - Preserve path-containment and symlink fail-closed behavior. diff --git a/README.md b/README.md index f5cdf80..a10141d 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,14 @@ new agent, or scope it to one project: context-tree install # every agent you have context-tree install --host codex # one agent context-tree install --project . # ./.claude/skills and ./.codex/skills +context-tree uninstall # remove context-tree-* skills ``` -Installing only ever writes `context-tree-*` skill directories, never touches -skills it does not own, and never creates a configuration directory for an agent -that is not present. Adding support for another agent is one entry in the host -table in `src/core/install.ts`. +Install and uninstall own exactly the `context-tree-*` skill directories. +Install never touches skills it does not own or creates a configuration directory +for an agent that is not present; uninstall removes every owned-prefix directory +and nothing else. Adding support for another agent is one entry in the host table +in `src/core/install.ts`. Once a project is connected, `create` and `connect` record the tree in the project's own `AGENTS.md`, so any agent that reads instruction files knows the @@ -181,13 +183,14 @@ commit or discard them), `INVALID_TREE` (structure fails `verify`), The public command inventory is: ```text -install create connect list resolve sync prepare-write +install uninstall create connect list resolve sync prepare-write finish-write publish read verify ``` Setup, create, connect, read, write, and publish ship as six skills; setup orchestrates the five concrete workflows. `install` is the distribution -entry point, run for you by `npm install`. `resolve`, `sync`, `prepare-write`, +entry point, run for you by `npm install`; `uninstall` is its supported reverse. +`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. @@ -197,7 +200,7 @@ separate user intentions; `list` backs setup's connect-target discovery. 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 +pass `--json`. `sync`, `prepare-write`, `finish-write`, `install`, and `uninstall` are low-level plumbing and always emit that JSON (with the error envelope on stdout). `--help` and `--version` are always plain text. diff --git a/scripts/package-e2e.mjs b/scripts/package-e2e.mjs index 5efb75f..1564e9d 100644 --- a/scripts/package-e2e.mjs +++ b/scripts/package-e2e.mjs @@ -179,6 +179,22 @@ try { "postinstall must not create an absent host directory", ); + const foreignSkill = join(temporaryRoot, ".claude", "skills", "foreign-skill"); + mkdirSync(foreignSkill); + const contextTreeState = join(temporaryRoot, ".context-tree", "trees", "preserved"); + mkdirSync(contextTreeState, { recursive: true }); + const uninstall = runCli(join(globalPrefix, "bin/context-tree"), temporaryRoot, ["uninstall"]); + assert.equal(uninstall.status, 0); + assert.equal(uninstall.stdout.trim().split("\n").length, 1, "uninstall must print one JSON line"); + const uninstallResult = parseOneLineJson(uninstall.stdout); + assert.deepEqual(uninstallResult.removed[0].skills, SKILLS); + for (const skill of SKILLS) { + assert.equal(existsSync(join(temporaryRoot, ".claude", "skills", skill)), false); + } + assert.equal(existsSync(foreignSkill), true, "uninstall must preserve foreign skills"); + assert.equal(existsSync(contextTreeState), true, "uninstall must preserve Context Tree state"); + assert.equal(existsSync(join(temporaryRoot, ".codex")), false, "uninstall must not create absent hosts"); + const cliPath = join(consumerRoot, "node_modules/.bin/context-tree"); const help = runCli(cliPath, consumerRoot, ["--help"]); diff --git a/src/cli/api.ts b/src/cli/api.ts index 5796d56..515cbb4 100644 --- a/src/cli/api.ts +++ b/src/cli/api.ts @@ -3,7 +3,12 @@ import { resolve } from "node:path"; import { Command, CommanderError } from "commander"; import { connectProject, listManagedTrees, resolveConnection } from "../core/connections.js"; import { createProject } from "../core/create.js"; -import { type InstallSkillsOptions, installSkills } from "../core/install.js"; +import { + type InstallSkillsOptions, + installSkills, + type UninstallSkillsOptions, + uninstallSkills, +} from "../core/install.js"; import { ContextTreeError } from "../core/internal/errors.js"; import { sanitizeCommandOutput } from "../core/internal/git.js"; import { readPackageVersion } from "../core/internal/packaged-resource.js"; @@ -200,6 +205,18 @@ function createContextTreeCli(io: ContextTreeCliIo = defaultIo): Command { line(io, JSON.stringify(installSkills(request))); }); + program + .command("uninstall") + .description("Remove packaged Context Tree skills from each agent's skill directory.") + .option("--host ", "restrict to one host: claude, codex, or all", "all") + .option("--project ", "remove below this project root instead of the home directory") + .action((options: { host: string; project?: string }) => { + const request: UninstallSkillsOptions = {}; + if (options.host !== "all") request.hosts = [skillHostSchema.parse(options.host)]; + if (options.project !== undefined) request.projectPath = resolve(io.cwd(), options.project); + line(io, JSON.stringify(uninstallSkills(request))); + }); + return program; } diff --git a/src/core/install.ts b/src/core/install.ts index 56411f3..bd7827f 100644 --- a/src/core/install.ts +++ b/src/core/install.ts @@ -9,6 +9,7 @@ import { type SkillHost, type SkillInstallation, type SkillInstallSkip, + type UninstallSkillsResult, } from "../schemas.js"; import { readPackageVersion, resolvePackagedResource } from "./internal/packaged-resource.js"; @@ -31,6 +32,13 @@ export type InstallSkillsOptions = { projectPath?: string; }; +export type UninstallSkillsOptions = { + /** Restrict removal to these hosts; defaults to every known host. */ + hosts?: readonly SkillHost[]; + /** Remove below this project root instead of the home directory. */ + projectPath?: string; +}; + function realHome(): string { try { return realpathSync(homedir()); @@ -98,6 +106,22 @@ function hostDestination( return { destination: ensureRealDirectory(root, [configDirectory, SKILLS_DIRECTORY]) }; } +/** Resolve one host's existing skills root without creating or following anything. */ +function hostSkillsRoot(host: SkillHost, root: string): { destination: string } | { reason: string } { + const hostRoot = join(root, HOST_CONFIG_DIRECTORY[host]); + const hostEntry = lstatSync(hostRoot, { throwIfNoEntry: false }); + if (hostEntry === undefined) return { reason: `${hostRoot} does not exist; nothing to remove.` }; + if (hostEntry.isSymbolicLink() || !hostEntry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` }; + + const skillsRoot = join(hostRoot, SKILLS_DIRECTORY); + const skillsEntry = lstatSync(skillsRoot, { throwIfNoEntry: false }); + if (skillsEntry === undefined) return { reason: `${skillsRoot} does not exist; nothing to remove.` }; + if (skillsEntry.isSymbolicLink() || !skillsEntry.isDirectory()) { + return { reason: `${skillsRoot} is not a real directory.` }; + } + return { destination: skillsRoot }; +} + /** * Copy the packaged skills into each requested host's skill directory. * @@ -135,3 +159,36 @@ export function installSkills(options: InstallSkillsOptions = {}): InstallSkills return { installed, schemaVersion: SCHEMA_VERSION, skipped, version: readPackageVersion() }; } + +/** Remove every skill owned by the `context-tree-` prefix from each requested host. */ +export function uninstallSkills(options: UninstallSkillsOptions = {}): UninstallSkillsResult { + const hosts = options.hosts === undefined || options.hosts.length === 0 ? SKILL_HOSTS : options.hosts; + const root = options.projectPath === undefined ? realHome() : resolve(options.projectPath); + const removed: SkillInstallation[] = []; + const skipped: SkillInstallSkip[] = []; + + for (const host of hosts) { + const resolved = hostSkillsRoot(host, root); + if ("reason" in resolved) { + skipped.push({ host, reason: resolved.reason }); + continue; + } + + const skills: string[] = []; + for (const entry of readdirSync(resolved.destination, { withFileTypes: true })) { + if (!entry.name.startsWith(OWNED_SKILL_PREFIX)) continue; + const target = join(resolved.destination, entry.name); + const targetEntry = lstatSync(target, { throwIfNoEntry: false }); + if (targetEntry === undefined) continue; + if (targetEntry.isSymbolicLink() || !targetEntry.isDirectory()) { + skipped.push({ host, reason: `${target} is not a real directory.` }); + continue; + } + rmSync(target, { force: true, recursive: true }); + skills.push(entry.name); + } + removed.push({ host, path: resolved.destination, skills: skills.sort() }); + } + + return { removed, schemaVersion: SCHEMA_VERSION, skipped, version: readPackageVersion() }; +} diff --git a/src/schemas.ts b/src/schemas.ts index 9859aaf..24bf3d8 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -175,6 +175,16 @@ export const installSkillsResultSchema = z .strict(); export type InstallSkillsResult = z.infer; +export const uninstallSkillsResultSchema = z + .object({ + removed: z.array(skillInstallationSchema), + schemaVersion: z.literal(SCHEMA_VERSION), + skipped: z.array(skillInstallSkipSchema), + version: z.string().trim().min(1), + }) + .strict(); +export type UninstallSkillsResult = z.infer; + const contextTreeReadKindSchema = z.enum(["directory", "file"]); const contextTreeReadCommonFields = { contentClass: contextContentClassSchema, diff --git a/tests/cli.test.ts b/tests/cli.test.ts index fcc6a5f..9da13bb 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -131,6 +131,7 @@ describe("built CLI", () => { "read", "resolve", "sync", + "uninstall", "verify", ]); const version = cli(workspace(), ["--version"]); diff --git a/tests/install.test.ts b/tests/install.test.ts index cf6bfd9..e804eed 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { installSkills } from "../src/core/install.js"; +import { installSkills, uninstallSkills } from "../src/core/install.js"; const SKILLS = [ "context-tree-connect", @@ -105,3 +105,80 @@ describe("skill installation", () => { expect(() => installSkills({ hosts: ["claude"], projectPath: root })).toThrow(/real directory/u); }); }); + +describe("skill removal", () => { + it("removes every packaged skill it installed for a named host", () => { + const root = workspace(); + installSkills({ hosts: ["claude"], projectPath: root }); + + const result = uninstallSkills({ hosts: ["claude"], projectPath: root }); + + expect(result.removed).toEqual([{ host: "claude", path: join(root, ".claude", "skills"), skills: SKILLS }]); + expect(result.skipped).toEqual([]); + for (const skill of SKILLS) expect(existsSync(join(root, ".claude", "skills", skill))).toBe(false); + }); + + it("removes a context-tree- skill it did not install", () => { + const root = workspace(); + const owned = join(root, ".claude", "skills", "context-tree-custom"); + mkdirSync(owned, { recursive: true }); + + const result = uninstallSkills({ hosts: ["claude"], projectPath: root }); + + expect(result.removed[0]?.skills).toEqual(["context-tree-custom"]); + expect(existsSync(owned)).toBe(false); + }); + + it("never touches a skill directory the package does not own", () => { + const root = workspace(); + const foreign = join(root, ".claude", "skills", "someone-elses-skill"); + mkdirSync(foreign, { recursive: true }); + writeFileSync(join(foreign, "SKILL.md"), "mine\n"); + + uninstallSkills({ hosts: ["claude"], projectPath: root }); + + expect(readFileSync(join(foreign, "SKILL.md"), "utf8")).toBe("mine\n"); + }); + + it("reports nothing to remove for a host that is not installed", () => { + const root = workspace(); + const result = uninstallSkills({ hosts: ["claude"], projectPath: root }); + expect(result.removed).toEqual([]); + expect(result.skipped[0]?.reason).toBe(`${join(root, ".claude")} does not exist; nothing to remove.`); + }); + + it("is idempotent", () => { + const root = workspace(); + installSkills({ hosts: ["claude"], projectPath: root }); + uninstallSkills({ hosts: ["claude"], projectPath: root }); + + expect(() => uninstallSkills({ hosts: ["claude"], projectPath: root })).not.toThrow(); + expect(uninstallSkills({ hosts: ["claude"], projectPath: root }).removed[0]?.skills).toEqual([]); + }); + + it("leaves everything outside the host skills directory alone", () => { + const root = workspace(); + const state = join(root, ".context-tree", "trees", "mine"); + mkdirSync(state, { recursive: true }); + installSkills({ hosts: ["claude"], projectPath: root }); + + uninstallSkills({ hosts: ["claude"], projectPath: root }); + + expect(existsSync(state)).toBe(true); + }); + + it("refuses to remove through a symlinked skills directory", () => { + const root = workspace(); + const outside = join(root, "outside"); + const owned = join(outside, "context-tree-read"); + mkdirSync(owned, { recursive: true }); + mkdirSync(join(root, ".claude")); + symlinkSync(outside, join(root, ".claude", "skills"), "dir"); + + const result = uninstallSkills({ hosts: ["claude"], projectPath: root }); + + expect(result.removed).toEqual([]); + expect(result.skipped[0]?.reason).toContain("is not a real directory"); + expect(existsSync(owned)).toBe(true); + }); +});