argsbarg 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.private/scratch.md +2 -1
  2. package/CHANGELOG.md +29 -1
  3. package/README.md +22 -7
  4. package/docs/ai-skills.md +47 -0
  5. package/docs/install.md +84 -0
  6. package/docs/mcp.md +7 -5
  7. package/index.d.ts +11 -9
  8. package/package.json +1 -1
  9. package/src/builtins/builtins.test.ts +101 -0
  10. package/src/builtins/completion-bash.ts +240 -0
  11. package/src/builtins/completion-fish.ts +73 -0
  12. package/src/builtins/completion-group.ts +50 -0
  13. package/src/builtins/completion-zsh.ts +244 -0
  14. package/src/builtins/dispatch.ts +123 -0
  15. package/src/builtins/export.ts +46 -0
  16. package/src/builtins/index.ts +10 -0
  17. package/src/builtins/install.ts +99 -0
  18. package/src/builtins/mcp.ts +13 -0
  19. package/src/builtins/presentation.ts +39 -0
  20. package/src/builtins/scopes.ts +45 -0
  21. package/src/builtins/shell-helpers.ts +24 -0
  22. package/src/completion.ts +10 -652
  23. package/src/index.test.ts +135 -4
  24. package/src/index.ts +1 -0
  25. package/src/install/binary.ts +82 -0
  26. package/src/install/compiled.ts +15 -0
  27. package/src/install/completions.ts +52 -0
  28. package/src/install/detect-installed.ts +67 -0
  29. package/src/install/index.ts +196 -0
  30. package/src/install/install.test.ts +124 -0
  31. package/src/install/mcp-config.ts +70 -0
  32. package/src/install/paths.ts +69 -0
  33. package/src/install/plan.ts +183 -0
  34. package/src/install/shell.ts +56 -0
  35. package/src/install/status.ts +63 -0
  36. package/src/install/uninstall.ts +111 -0
  37. package/src/mcp/tools.ts +1 -1
  38. package/src/runtime.ts +23 -66
  39. package/src/schema.ts +7 -49
  40. package/src/skill/generate.ts +183 -0
  41. package/src/skill/install.ts +47 -0
  42. package/src/types.ts +12 -0
  43. package/src/validate.ts +14 -20
@@ -0,0 +1,124 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { CliCommand } from "../types.ts";
6
+ import { detectInstalledArtifacts } from "./detect-installed.ts";
7
+ import { resolveInstallPaths } from "./paths.ts";
8
+ import { buildInstallPlan } from "./plan.ts";
9
+ import { printInstallStatus } from "./status.ts";
10
+ import { parseInstallOpts } from "./index.ts";
11
+
12
+ const fixture: CliCommand = {
13
+ key: "testapp",
14
+ description: "Test",
15
+ mcpServer: { name: "testapp" },
16
+ handler: () => {},
17
+ };
18
+
19
+ let home: string;
20
+ let prevHome: string | undefined;
21
+
22
+ beforeEach(() => {
23
+ home = mkdtempSync(join(tmpdir(), "argsbarg-install-"));
24
+ prevHome = process.env.HOME;
25
+ process.env.HOME = home;
26
+ });
27
+
28
+ afterEach(() => {
29
+ if (prevHome === undefined) delete process.env.HOME;
30
+ else process.env.HOME = prevHome;
31
+ rmSync(home, { recursive: true, force: true });
32
+ });
33
+
34
+ describe("install paths", () => {
35
+ test("resolveInstallPaths honors prefix", () => {
36
+ const paths = resolveInstallPaths(fixture, { prefix: join(home, "custom", "bin") });
37
+ expect(paths.binaryPath).toBe(join(home, "custom", "bin", "testapp"));
38
+ });
39
+
40
+ test("fish completion uses XDG_CONFIG_HOME", () => {
41
+ const xdg = join(home, "xdg");
42
+ process.env.XDG_CONFIG_HOME = xdg;
43
+ const paths = resolveInstallPaths(fixture, {});
44
+ expect(paths.fishCompletion).toBe(join(xdg, "fish", "completions", "testapp.fish"));
45
+ });
46
+ });
47
+
48
+ describe("detect installed", () => {
49
+ test("detects binary and completions", () => {
50
+ const paths = resolveInstallPaths(fixture, {});
51
+ mkdirSync(paths.bindir, { recursive: true });
52
+ writeFileSync(paths.binaryPath, "fake", "utf8");
53
+ mkdirSync(join(home, ".bash_completion.d"), { recursive: true });
54
+ writeFileSync(paths.bashCompletion, "# bash", "utf8");
55
+
56
+ const detected = detectInstalledArtifacts(paths);
57
+ expect(detected.binary).toBe(true);
58
+ expect(detected.bashCompletion).toBe(true);
59
+ expect(detected.zshCompletion).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("install plan", () => {
64
+ test("buildInstallPlan --all includes binary", () => {
65
+ const paths = resolveInstallPaths(fixture, {});
66
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ all: "1" }));
67
+ expect(plan.some((a) => a.kind === "binary")).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("install status", () => {
72
+ test("printInstallStatus human output", () => {
73
+ const paths = resolveInstallPaths(fixture, {});
74
+ mkdirSync(paths.bindir, { recursive: true });
75
+ writeFileSync(paths.binaryPath, "fake", "utf8");
76
+
77
+ const chunks: string[] = [];
78
+ const orig = process.stdout.write;
79
+ process.stdout.write = ((s: string) => {
80
+ chunks.push(s);
81
+ return true;
82
+ }) as typeof process.stdout.write;
83
+ try {
84
+ printInstallStatus(fixture, {});
85
+ const out = chunks.join("");
86
+ expect(out).toContain("Installed artifacts for testapp");
87
+ expect(out).toContain(paths.binaryPath);
88
+ } finally {
89
+ process.stdout.write = orig;
90
+ }
91
+ });
92
+
93
+ test("printInstallStatus json output", () => {
94
+ const paths = resolveInstallPaths(fixture, {});
95
+ mkdirSync(join(home, ".cursor"), { recursive: true });
96
+ writeFileSync(
97
+ paths.cursorMcpPath,
98
+ JSON.stringify({ mcpServers: { testapp: { command: "testapp", args: ["mcp"] } } }),
99
+ "utf8",
100
+ );
101
+
102
+ const chunks: string[] = [];
103
+ const orig = process.stdout.write;
104
+ process.stdout.write = ((s: string) => {
105
+ chunks.push(s);
106
+ return true;
107
+ }) as typeof process.stdout.write;
108
+ try {
109
+ printInstallStatus(fixture, { json: true });
110
+ const data = JSON.parse(chunks.join(""));
111
+ expect(data.cursorMcp).toContain("mcp.json");
112
+ } finally {
113
+ process.stdout.write = orig;
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("parseInstallOpts", () => {
119
+ test("json implies yes in validate path via cliInstall", () => {
120
+ const opts = parseInstallOpts({ json: "1", all: "1" });
121
+ expect(opts.json).toBe(true);
122
+ expect(opts.all).toBe(true);
123
+ });
124
+ });
@@ -0,0 +1,70 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { CliCommand } from "../types.ts";
4
+ import { InstallPaths } from "./paths.ts";
5
+
6
+ export interface McpServerEntry {
7
+ command: string;
8
+ args: string[];
9
+ }
10
+
11
+ export function expectedMcpEntry(root: CliCommand): McpServerEntry {
12
+ return { command: root.key, args: ["mcp"] };
13
+ }
14
+
15
+ function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean {
16
+ return a.command === b.command && JSON.stringify(a.args) === JSON.stringify(b.args);
17
+ }
18
+
19
+ /** Reads mcpServers[name] from a JSON config file, or undefined. */
20
+ export function readMcpServerEntry(path: string, name: string): McpServerEntry | undefined {
21
+ if (!existsSync(path)) return undefined;
22
+ try {
23
+ const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, McpServerEntry> };
24
+ return data.mcpServers?.[name];
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ /** Returns an error message when existing entry conflicts, or null if safe to merge. */
31
+ export function checkMcpConflict(
32
+ path: string,
33
+ name: string,
34
+ expected: McpServerEntry,
35
+ yes: boolean,
36
+ ): string | null {
37
+ const existing = readMcpServerEntry(path, name);
38
+ if (existing && !entriesEqual(existing, expected) && !yes) {
39
+ return (
40
+ `MCP server "${name}" in ${path} differs from expected entry.\n` +
41
+ ` existing: ${JSON.stringify(existing)}\n` +
42
+ ` expected: ${JSON.stringify(expected)}\n` +
43
+ `Use --yes to overwrite.`
44
+ );
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** Merges MCP server entry into config file. */
50
+ export function mergeMcpConfig(path: string, name: string, entry: McpServerEntry, dry: boolean): void {
51
+ if (dry) return;
52
+ let data: Record<string, unknown> = {};
53
+ if (existsSync(path)) {
54
+ data = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
55
+ }
56
+ const servers = (data.mcpServers as Record<string, McpServerEntry> | undefined) ?? {};
57
+ servers[name] = entry;
58
+ data.mcpServers = servers;
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
61
+ }
62
+
63
+ /** Removes MCP server entry from config file (keeps file if other keys remain). */
64
+ export function removeMcpConfig(path: string, name: string, dry: boolean): void {
65
+ if (dry || !existsSync(path)) return;
66
+ const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, unknown> };
67
+ if (!data.mcpServers?.[name]) return;
68
+ delete data.mcpServers[name];
69
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
70
+ }
@@ -0,0 +1,69 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { CliCommand } from "../types.ts";
4
+ import { sanitizeToolSegment } from "../mcp/tools.ts";
5
+
6
+ export interface InstallPaths {
7
+ bindir: string;
8
+ binaryPath: string;
9
+ bashCompletion: string;
10
+ zshCompletion: string;
11
+ fishCompletion: string;
12
+ cursorSkillDir: string;
13
+ claudeSkillDir: string;
14
+ cursorMcpPath: string;
15
+ claudeMcpPath: string;
16
+ bashRc: string;
17
+ zshRc: string;
18
+ mcpName: string;
19
+ skillDirName: string;
20
+ }
21
+
22
+ /** Resolves the user home directory (`$HOME` when set). */
23
+ export function userHome(): string {
24
+ return process.env.HOME ?? homedir();
25
+ }
26
+
27
+ function expandTilde(path: string): string {
28
+ if (path.startsWith("~/")) {
29
+ return join(userHome(), path.slice(2));
30
+ }
31
+ if (path === "~") {
32
+ return userHome();
33
+ }
34
+ return path;
35
+ }
36
+
37
+ /** Resolves the binary install directory from CLI flag, env, or config. */
38
+ export function resolveBindir(root: CliCommand, prefix?: string): string {
39
+ const raw = prefix ?? process.env.INSTALL_PREFIX ?? root.install?.prefix;
40
+ if (raw) {
41
+ return expandTilde(raw);
42
+ }
43
+ return join(userHome(), ".local", "bin");
44
+ }
45
+
46
+ /** Resolves all install artifact paths for a program root. */
47
+ export function resolveInstallPaths(root: CliCommand, opts: { prefix?: string }): InstallPaths {
48
+ const home = userHome();
49
+ const bindir = resolveBindir(root, opts.prefix);
50
+ const key = root.key;
51
+ const skillDirName = sanitizeToolSegment(root.key);
52
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
53
+
54
+ return {
55
+ bindir,
56
+ binaryPath: join(bindir, key),
57
+ bashCompletion: join(home, ".bash_completion.d", key),
58
+ zshCompletion: join(home, ".zsh", "completions", `_${key}`),
59
+ fishCompletion: join(xdgConfig, "fish", "completions", `${key}.fish`),
60
+ cursorSkillDir: join(home, ".cursor", "skills", skillDirName),
61
+ claudeSkillDir: join(home, ".claude", "skills", skillDirName),
62
+ cursorMcpPath: join(home, ".cursor", "mcp.json"),
63
+ claudeMcpPath: join(home, ".claude.json"),
64
+ bashRc: join(home, ".bashrc"),
65
+ zshRc: join(home, ".zshrc"),
66
+ mcpName: root.mcpServer?.name ?? root.key,
67
+ skillDirName,
68
+ };
69
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CliCommand } from "../types.ts";
4
+ import { installBinary } from "./binary.ts";
5
+ import { installCompletions } from "./completions.ts";
6
+ import { detectInstalledArtifacts } from "./detect-installed.ts";
7
+ import { expectedMcpEntry, mergeMcpConfig } from "./mcp-config.ts";
8
+ import { InstallPaths, userHome } from "./paths.ts";
9
+ import { detectShells } from "./shell.ts";
10
+
11
+ export interface InstallOpts {
12
+ all?: boolean;
13
+ bin?: boolean;
14
+ completions?: boolean;
15
+ skill?: boolean;
16
+ mcp?: boolean;
17
+ update?: boolean;
18
+ status?: boolean;
19
+ uninstall?: boolean;
20
+ yes?: boolean;
21
+ dry?: boolean;
22
+ json?: boolean;
23
+ quiet?: boolean;
24
+ prefix?: string;
25
+ }
26
+
27
+ export type InstallActionKind = "binary" | "completions" | "cursor-skill" | "claude-skill" | "cursor-mcp" | "claude-mcp";
28
+
29
+ export interface InstallAction {
30
+ kind: InstallActionKind;
31
+ summary: string;
32
+ message: string;
33
+ run: () => string[];
34
+ }
35
+
36
+ function wantsBin(opts: InstallOpts): boolean {
37
+ return !!(opts.all || opts.bin || opts.update);
38
+ }
39
+
40
+ function wantsCompletions(opts: InstallOpts): boolean {
41
+ return !!(opts.all || opts.completions);
42
+ }
43
+
44
+ function wantsSkill(opts: InstallOpts): boolean {
45
+ return !!(opts.all || opts.skill);
46
+ }
47
+
48
+ function wantsMcp(opts: InstallOpts, root: CliCommand): boolean {
49
+ return !!(opts.all || opts.mcp) && root.mcpServer !== undefined;
50
+ }
51
+
52
+ /** Builds install actions for normal mode (--all / scoped targets). */
53
+ export function buildInstallPlan(root: CliCommand, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
54
+ const actions: InstallAction[] = [];
55
+ const dry = !!opts.dry;
56
+
57
+ if (wantsBin(opts)) {
58
+ actions.push({
59
+ kind: "binary",
60
+ summary: `binary: ${paths.binaryPath}`,
61
+ message: `Installing binary to ${paths.binaryPath}`,
62
+ run: () => installBinary(root, paths, dry).changedFiles,
63
+ });
64
+ }
65
+
66
+ if (wantsCompletions(opts)) {
67
+ const shells = detectShells();
68
+ if (shells.bash) {
69
+ actions.push({
70
+ kind: "completions",
71
+ summary: `bash completion: ${paths.bashCompletion}`,
72
+ message: `Writing bash completion to ${paths.bashCompletion}`,
73
+ run: () => {
74
+ const all = installCompletions(root, paths, dry);
75
+ return all.filter((p) => p === paths.bashCompletion);
76
+ },
77
+ });
78
+ }
79
+ if (shells.zsh) {
80
+ actions.push({
81
+ kind: "completions",
82
+ summary: `zsh completion: ${paths.zshCompletion}`,
83
+ message: `Writing zsh completion to ${paths.zshCompletion}`,
84
+ run: () => {
85
+ const all = installCompletions(root, paths, dry);
86
+ return all.filter((p) => p === paths.zshCompletion);
87
+ },
88
+ });
89
+ }
90
+ if (shells.fish) {
91
+ actions.push({
92
+ kind: "completions",
93
+ summary: `fish completion: ${paths.fishCompletion}`,
94
+ message: `Writing fish completion to ${paths.fishCompletion}`,
95
+ run: () => {
96
+ const all = installCompletions(root, paths, dry);
97
+ return all.filter((p) => p === paths.fishCompletion);
98
+ },
99
+ });
100
+ }
101
+ }
102
+
103
+ if (wantsSkill(opts)) {
104
+ const home = userHome();
105
+ if (existsSync(join(home, ".cursor"))) {
106
+ actions.push({
107
+ kind: "cursor-skill",
108
+ summary: `cursor skill: ${paths.cursorSkillDir}/`,
109
+ message: `Installing Cursor skill to ${paths.cursorSkillDir}/`,
110
+ run: () => [],
111
+ });
112
+ }
113
+ if (existsSync(join(home, ".claude"))) {
114
+ actions.push({
115
+ kind: "claude-skill",
116
+ summary: `claude skill: ${paths.claudeSkillDir}/`,
117
+ message: `Installing Claude Code skill to ${paths.claudeSkillDir}/`,
118
+ run: () => [],
119
+ });
120
+ }
121
+ }
122
+
123
+ if (wantsMcp(opts, root)) {
124
+ const entry = expectedMcpEntry(root);
125
+ if (existsSync(join(userHome(), ".cursor"))) {
126
+ actions.push({
127
+ kind: "cursor-mcp",
128
+ summary: `cursor mcp: ${paths.cursorMcpPath} (server "${paths.mcpName}")`,
129
+ message: `Merging MCP server "${paths.mcpName}" into ${paths.cursorMcpPath}`,
130
+ run: () => {
131
+ mergeMcpConfig(paths.cursorMcpPath, paths.mcpName, entry, dry);
132
+ return [paths.cursorMcpPath];
133
+ },
134
+ });
135
+ }
136
+ actions.push({
137
+ kind: "claude-mcp",
138
+ summary: `claude mcp: ${paths.claudeMcpPath} (server "${paths.mcpName}")`,
139
+ message: `Merging MCP server "${paths.mcpName}" into ${paths.claudeMcpPath}`,
140
+ run: () => {
141
+ mergeMcpConfig(paths.claudeMcpPath, paths.mcpName, entry, dry);
142
+ return [paths.claudeMcpPath];
143
+ },
144
+ });
145
+ }
146
+
147
+ return actions;
148
+ }
149
+
150
+ /** Builds update actions for artifacts already installed. */
151
+ export function buildUpdatePlan(root: CliCommand, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
152
+ const detected = detectInstalledArtifacts(paths);
153
+ const scoped: InstallOpts = {
154
+ bin: true,
155
+ completions: detected.bashCompletion || detected.zshCompletion || detected.fishCompletion,
156
+ skill: detected.cursorSkill || detected.claudeSkill,
157
+ mcp: (detected.cursorMcp || detected.claudeMcp) && root.mcpServer !== undefined,
158
+ dry: opts.dry,
159
+ };
160
+ const plan = buildInstallPlan(root, paths, scoped);
161
+
162
+ return plan.filter((action) => {
163
+ switch (action.kind) {
164
+ case "binary":
165
+ return true;
166
+ case "completions":
167
+ if (action.summary.startsWith("bash")) return detected.bashCompletion;
168
+ if (action.summary.startsWith("zsh")) return detected.zshCompletion;
169
+ if (action.summary.startsWith("fish")) return detected.fishCompletion;
170
+ return false;
171
+ case "cursor-skill":
172
+ return detected.cursorSkill;
173
+ case "claude-skill":
174
+ return detected.claudeSkill;
175
+ case "cursor-mcp":
176
+ return detected.cursorMcp;
177
+ case "claude-mcp":
178
+ return detected.claudeMcp;
179
+ default:
180
+ return false;
181
+ }
182
+ });
183
+ }
@@ -0,0 +1,56 @@
1
+ export interface ShellDetection {
2
+ bash: boolean;
3
+ zsh: boolean;
4
+ fish: boolean;
5
+ }
6
+
7
+ /** Detects which shells are available on PATH. */
8
+ export function detectShells(): ShellDetection {
9
+ return {
10
+ bash: Bun.which("bash") !== null,
11
+ zsh: Bun.which("zsh") !== null,
12
+ fish: Bun.which("fish") !== null,
13
+ };
14
+ }
15
+
16
+ export function rcMarkerStart(appKey: string, tag: string): string {
17
+ return `# ${appKey}:${tag}`;
18
+ }
19
+
20
+ export function rcMarkerEnd(appKey: string, tag: string): string {
21
+ return `# end ${appKey}:${tag}`;
22
+ }
23
+
24
+ /** Returns rc snippet block for PATH, or null if already present. */
25
+ export function buildPathRcBlock(appKey: string, bindir: string): string {
26
+ const start = rcMarkerStart(appKey, "path");
27
+ const end = rcMarkerEnd(appKey, "path");
28
+ return [start, `export PATH="${bindir}:$PATH"`, end].join("\n");
29
+ }
30
+
31
+ /** Returns rc snippet block for zsh fpath, or null if already present. */
32
+ export function buildZshFpathRcBlock(appKey: string, completionsDir: string): string {
33
+ const start = rcMarkerStart(appKey, "fpath");
34
+ const end = rcMarkerEnd(appKey, "fpath");
35
+ return [start, `fpath=(${completionsDir} $fpath)`, end].join("\n");
36
+ }
37
+
38
+ /** Removes a marker-delimited block from rc file content. */
39
+ export function removeRcBlock(content: string, appKey: string, tag: string): string {
40
+ const start = rcMarkerStart(appKey, tag);
41
+ const end = rcMarkerEnd(appKey, tag);
42
+ const re = new RegExp(
43
+ `${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`,
44
+ "g",
45
+ );
46
+ return content.replace(re, "");
47
+ }
48
+
49
+ /** Returns true when the marker block already exists in content. */
50
+ export function hasRcBlock(content: string, appKey: string, tag: string): boolean {
51
+ return content.includes(rcMarkerStart(appKey, tag));
52
+ }
53
+
54
+ function escapeRegExp(s: string): string {
55
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ }
@@ -0,0 +1,63 @@
1
+ import { CliCommand } from "../types.ts";
2
+ import { buildInstallStatus, detectInstalledArtifacts } from "./detect-installed.ts";
3
+ import type { InstallOpts } from "./plan.ts";
4
+ import { resolveInstallPaths } from "./paths.ts";
5
+
6
+ export function installOut(msg: string, opts: InstallOpts): void {
7
+ if (opts.quiet || opts.json) return;
8
+ process.stdout.write(msg + "\n");
9
+ }
10
+
11
+ export function installInfo(msg: string, opts: InstallOpts): void {
12
+ if (opts.quiet) return;
13
+ if (opts.json && !opts.dry) return;
14
+ const prefix = opts.dry ? "[dry run] " : "";
15
+ process.stderr.write(prefix + msg + "\n");
16
+ }
17
+
18
+ export function installErr(msg: string): void {
19
+ process.stderr.write(msg + "\n");
20
+ }
21
+
22
+ /** Prints install status to stdout (human or JSON). */
23
+ export function printInstallStatus(root: CliCommand, opts: InstallOpts): void {
24
+ const paths = resolveInstallPaths(root, opts);
25
+ const detected = detectInstalledArtifacts(paths);
26
+ const status = buildInstallStatus(paths, detected);
27
+
28
+ if (opts.json) {
29
+ const json: Record<string, string> = {};
30
+ if (status.binary) json.binary = status.binary;
31
+ if (status.bashCompletion) json.bashCompletion = status.bashCompletion;
32
+ if (status.zshCompletion) json.zshCompletion = status.zshCompletion;
33
+ if (status.fishCompletion) json.fishCompletion = status.fishCompletion;
34
+ if (status.cursorSkill) json.cursorSkill = status.cursorSkill;
35
+ if (status.claudeSkill) json.claudeSkill = status.claudeSkill;
36
+ if (status.cursorMcp) json.cursorMcp = status.cursorMcp;
37
+ if (status.claudeMcp) json.claudeMcp = status.claudeMcp;
38
+ process.stdout.write(JSON.stringify(json, null, 2) + "\n");
39
+ return;
40
+ }
41
+
42
+ installOut(`Installed artifacts for ${root.key}:`, opts);
43
+ const lines: [string, string | undefined][] = [
44
+ ["binary", status.binary],
45
+ ["bash completion", status.bashCompletion],
46
+ ["zsh completion", status.zshCompletion],
47
+ ["fish completion", status.fishCompletion],
48
+ ["cursor skill", status.cursorSkill],
49
+ ["claude skill", status.claudeSkill],
50
+ ["cursor mcp", status.cursorMcp],
51
+ ["claude mcp", status.claudeMcp],
52
+ ];
53
+ let any = false;
54
+ for (const [label, value] of lines) {
55
+ if (value) {
56
+ installOut(` ${label}: ${value}`, opts);
57
+ any = true;
58
+ }
59
+ }
60
+ if (!any) {
61
+ installOut(" (none detected)", opts);
62
+ }
63
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CliCommand } from "../types.ts";
4
+ import { uninstallBinary } from "./binary.ts";
5
+ import { uninstallCompletions } from "./completions.ts";
6
+ import { detectInstalledArtifacts } from "./detect-installed.ts";
7
+ import { removeMcpConfig } from "./mcp-config.ts";
8
+ import { InstallPaths, userHome } from "./paths.ts";
9
+ import type { InstallOpts } from "./plan.ts";
10
+
11
+ export interface UninstallAction {
12
+ summary: string;
13
+ message: string;
14
+ run: () => string[];
15
+ }
16
+
17
+ function scopeAll(opts: InstallOpts): boolean {
18
+ return !opts.bin && !opts.completions && !opts.skill && !opts.mcp;
19
+ }
20
+
21
+ /** Builds uninstall actions from detected artifacts. */
22
+ export function buildUninstallPlan(
23
+ root: CliCommand,
24
+ paths: InstallPaths,
25
+ opts: InstallOpts,
26
+ ): UninstallAction[] {
27
+ const detected = detectInstalledArtifacts(paths);
28
+ const all = scopeAll(opts);
29
+ const dry = !!opts.dry;
30
+ const actions: UninstallAction[] = [];
31
+
32
+ if ((all || opts.bin) && detected.binary) {
33
+ actions.push({
34
+ summary: `binary: ${paths.binaryPath}`,
35
+ message: `Removing binary ${paths.binaryPath}`,
36
+ run: () => uninstallBinary(root, paths, dry),
37
+ });
38
+ }
39
+
40
+ if ((all || opts.completions) && (detected.bashCompletion || detected.zshCompletion || detected.fishCompletion)) {
41
+ if (detected.bashCompletion) {
42
+ actions.push({
43
+ summary: `bash completion: ${paths.bashCompletion}`,
44
+ message: `Removing bash completion ${paths.bashCompletion}`,
45
+ run: () => uninstallCompletions(paths, dry).filter((p) => p === paths.bashCompletion),
46
+ });
47
+ }
48
+ if (detected.zshCompletion) {
49
+ actions.push({
50
+ summary: `zsh completion: ${paths.zshCompletion}`,
51
+ message: `Removing zsh completion ${paths.zshCompletion}`,
52
+ run: () => uninstallCompletions(paths, dry).filter((p) => p === paths.zshCompletion),
53
+ });
54
+ }
55
+ if (detected.fishCompletion) {
56
+ actions.push({
57
+ summary: `fish completion: ${paths.fishCompletion}`,
58
+ message: `Removing fish completion ${paths.fishCompletion}`,
59
+ run: () => uninstallCompletions(paths, dry).filter((p) => p === paths.fishCompletion),
60
+ });
61
+ }
62
+ }
63
+
64
+ if ((all || opts.skill) && detected.cursorSkill) {
65
+ actions.push({
66
+ summary: `cursor skill: ${paths.cursorSkillDir}/`,
67
+ message: `Removing Cursor skill ${paths.cursorSkillDir}/`,
68
+ run: () => [],
69
+ });
70
+ }
71
+
72
+ if ((all || opts.skill) && detected.claudeSkill) {
73
+ actions.push({
74
+ summary: `claude skill: ${paths.claudeSkillDir}/`,
75
+ message: `Removing Claude Code skill ${paths.claudeSkillDir}/`,
76
+ run: () => [],
77
+ });
78
+ }
79
+
80
+ if ((all || opts.mcp) && root.mcpServer !== undefined) {
81
+ if (detected.cursorMcp) {
82
+ actions.push({
83
+ summary: `cursor mcp: ${paths.cursorMcpPath}`,
84
+ message: `Removing MCP server "${paths.mcpName}" from ${paths.cursorMcpPath}`,
85
+ run: () => {
86
+ removeMcpConfig(paths.cursorMcpPath, paths.mcpName, dry);
87
+ return [paths.cursorMcpPath];
88
+ },
89
+ });
90
+ }
91
+ if (detected.claudeMcp) {
92
+ actions.push({
93
+ summary: `claude mcp: ${paths.claudeMcpPath}`,
94
+ message: `Removing MCP server "${paths.mcpName}" from ${paths.claudeMcpPath}`,
95
+ run: () => {
96
+ removeMcpConfig(paths.claudeMcpPath, paths.mcpName, dry);
97
+ return [paths.claudeMcpPath];
98
+ },
99
+ });
100
+ }
101
+ }
102
+
103
+ return actions;
104
+ }
105
+
106
+ /** Rimraf skill directories during uninstall. */
107
+ export function uninstallSkillDir(dir: string, dry: boolean): string[] {
108
+ if (!existsSync(dir)) return [];
109
+ if (!dry) rmSync(dir, { recursive: true, force: true });
110
+ return [dir + "/"];
111
+ }
package/src/mcp/tools.ts CHANGED
@@ -150,7 +150,7 @@ export function collectMcpTools(root: CliCommand): McpToolDef[] {
150
150
  /** Walks the command tree and appends leaf tools. */
151
151
  function walk(cmd: CliCommand, path: string[]): void {
152
152
  if ("handler" in cmd && cmd.handler) {
153
- if (cmd.key === "completion" || cmd.key === "mcp") {
153
+ if (cmd.key === "completion" || cmd.key === "install" || cmd.key === "mcp") {
154
154
  return;
155
155
  }
156
156
  if (cmd.mcpTool?.enabled === false) {