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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions scripts/package-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
19 changes: 18 additions & 1 deletion src/cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <host>", "restrict to one host: claude, codex, or all", "all")
.option("--project <path>", "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;
}

Expand Down
57 changes: 57 additions & 0 deletions src/core/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type SkillHost,
type SkillInstallation,
type SkillInstallSkip,
type UninstallSkillsResult,
} from "../schemas.js";
import { readPackageVersion, resolvePackagedResource } from "./internal/packaged-resource.js";

Expand All @@ -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());
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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() };
}
10 changes: 10 additions & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ export const installSkillsResultSchema = z
.strict();
export type InstallSkillsResult = z.infer<typeof installSkillsResultSchema>;

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<typeof uninstallSkillsResultSchema>;

const contextTreeReadKindSchema = z.enum(["directory", "file"]);
const contextTreeReadCommonFields = {
contentClass: contextContentClassSchema,
Expand Down
1 change: 1 addition & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ describe("built CLI", () => {
"read",
"resolve",
"sync",
"uninstall",
"verify",
]);
const version = cli(workspace(), ["--version"]);
Expand Down
79 changes: 78 additions & 1 deletion tests/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});
});
Loading