argsbarg 1.4.3 → 2.0.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 (57) hide show
  1. package/.cursor/plans/cliprogram_capabilities_refactor_081e1737.plan.md +224 -0
  2. package/.private/scratch.md +1 -1
  3. package/CHANGELOG.md +39 -1
  4. package/README.md +29 -21
  5. package/docs/ai-skills.md +24 -52
  6. package/docs/install.md +84 -0
  7. package/docs/mcp.md +8 -8
  8. package/examples/mcp-test.ts +3 -3
  9. package/examples/minimal.ts +3 -3
  10. package/examples/nested.ts +3 -3
  11. package/examples/option-required.ts +3 -3
  12. package/index.d.ts +44 -50
  13. package/package.json +1 -1
  14. package/src/builtins/builtins.test.ts +101 -0
  15. package/src/builtins/completion-bash.ts +240 -0
  16. package/src/builtins/completion-fish.ts +73 -0
  17. package/src/builtins/completion-group.ts +50 -0
  18. package/src/builtins/completion-zsh.ts +244 -0
  19. package/src/builtins/dispatch.ts +138 -0
  20. package/src/builtins/export.ts +53 -0
  21. package/src/builtins/index.ts +10 -0
  22. package/src/builtins/install.ts +99 -0
  23. package/src/builtins/mcp.ts +13 -0
  24. package/src/builtins/presentation.ts +50 -0
  25. package/src/builtins/scopes.ts +46 -0
  26. package/src/builtins/shell-helpers.ts +24 -0
  27. package/src/capabilities.ts +32 -0
  28. package/src/completion.ts +10 -693
  29. package/src/context.ts +21 -6
  30. package/src/help.ts +21 -9
  31. package/src/index.test.ts +114 -118
  32. package/src/index.ts +2 -1
  33. package/src/install/binary.ts +82 -0
  34. package/src/install/compiled.ts +15 -0
  35. package/src/install/completions.ts +52 -0
  36. package/src/install/detect-installed.ts +67 -0
  37. package/src/install/index.ts +196 -0
  38. package/src/install/install.test.ts +124 -0
  39. package/src/install/mcp-config.ts +70 -0
  40. package/src/install/paths.ts +69 -0
  41. package/src/install/plan.ts +183 -0
  42. package/src/install/shell.ts +56 -0
  43. package/src/install/status.ts +63 -0
  44. package/src/install/uninstall.ts +111 -0
  45. package/src/invoke.ts +14 -5
  46. package/src/mcp/server.ts +3 -3
  47. package/src/mcp/tools.ts +17 -17
  48. package/src/mcp.ts +2 -2
  49. package/src/parse.ts +55 -27
  50. package/src/runtime.ts +47 -100
  51. package/src/schema.ts +10 -52
  52. package/src/skill/generate.ts +10 -10
  53. package/src/skill/install.ts +21 -19
  54. package/src/types.test.ts +40 -0
  55. package/src/types.ts +59 -49
  56. package/src/validate.ts +89 -83
  57. package/src/ai.ts +0 -7
package/src/index.ts CHANGED
@@ -13,12 +13,13 @@ export { CliContext } from "./context.ts";
13
13
  export { cliErrWithHelp, cliRun } from "./runtime";
14
14
  export { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
15
15
  export type {
16
- CliCommand,
16
+ CliProgram,
17
17
  CliHandler,
18
18
  CliInvocation,
19
19
  CliMcpResource,
20
20
  CliMcpServerConfig,
21
21
  CliMcpToolConfig,
22
+ CliInstallConfig,
22
23
  CliOption,
23
24
  CliPositional,
24
25
  } from "./types.ts";
@@ -0,0 +1,82 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CliProgram } from "../types.ts";
4
+ import { InstallPaths } from "./paths.ts";
5
+ import {
6
+ buildPathRcBlock,
7
+ buildZshFpathRcBlock,
8
+ detectShells,
9
+ hasRcBlock,
10
+ removeRcBlock,
11
+ } from "./shell.ts";
12
+
13
+ export interface BinaryInstallResult {
14
+ changedFiles: string[];
15
+ patchedBashRc: boolean;
16
+ patchedZshRc: boolean;
17
+ }
18
+
19
+ /** Copies the running binary to the install path and patches rc files when shells are detected. */
20
+ export function installBinary(root: CliProgram, paths: InstallPaths, dry: boolean): BinaryInstallResult {
21
+ const changed: string[] = [];
22
+ const shells = detectShells();
23
+ let patchedBashRc = false;
24
+ let patchedZshRc = false;
25
+
26
+ if (!dry) {
27
+ mkdirSync(paths.bindir, { recursive: true });
28
+ copyFileSync(process.execPath, paths.binaryPath);
29
+ }
30
+ changed.push(paths.binaryPath);
31
+
32
+ if (shells.bash && existsSync(dirname(paths.bashRc))) {
33
+ const block = buildPathRcBlock(root.key, paths.bindir);
34
+ let content = existsSync(paths.bashRc) ? readFileSync(paths.bashRc, "utf8") : "";
35
+ if (!hasRcBlock(content, root.key, "path")) {
36
+ if (!content.endsWith("\n") && content.length > 0) content += "\n";
37
+ content += block + "\n";
38
+ if (!dry) writeFileSync(paths.bashRc, content, "utf8");
39
+ changed.push(paths.bashRc);
40
+ patchedBashRc = true;
41
+ }
42
+ }
43
+
44
+ if (shells.zsh) {
45
+ const completionsDir = join(dirname(paths.zshCompletion));
46
+ const block = buildZshFpathRcBlock(root.key, completionsDir);
47
+ let content = existsSync(paths.zshRc) ? readFileSync(paths.zshRc, "utf8") : "";
48
+ if (!hasRcBlock(content, root.key, "fpath")) {
49
+ if (!content.endsWith("\n") && content.length > 0) content += "\n";
50
+ content += block + "\n";
51
+ if (!dry) writeFileSync(paths.zshRc, content, "utf8");
52
+ changed.push(paths.zshRc);
53
+ patchedZshRc = true;
54
+ }
55
+ }
56
+
57
+ return { changedFiles: changed, patchedBashRc, patchedZshRc };
58
+ }
59
+
60
+ /** Removes binary and rc marker blocks. */
61
+ export function uninstallBinary(root: CliProgram, paths: InstallPaths, dry: boolean): string[] {
62
+ const changed: string[] = [];
63
+ if (existsSync(paths.binaryPath)) {
64
+ if (!dry) unlinkSync(paths.binaryPath);
65
+ changed.push(paths.binaryPath);
66
+ }
67
+
68
+ for (const [rcPath, tag] of [
69
+ [paths.bashRc, "path"],
70
+ [paths.zshRc, "fpath"],
71
+ ] as const) {
72
+ if (!existsSync(rcPath)) continue;
73
+ const content = readFileSync(rcPath, "utf8");
74
+ if (hasRcBlock(content, root.key, tag)) {
75
+ const next = removeRcBlock(content, root.key, tag);
76
+ if (!dry) writeFileSync(rcPath, next, "utf8");
77
+ changed.push(rcPath);
78
+ }
79
+ }
80
+
81
+ return changed;
82
+ }
@@ -0,0 +1,15 @@
1
+ /** Test override: when set, `isCompiledExecutable()` returns this value instead of checking Bun.embeddedFiles. */
2
+ let compiledOverride: boolean | null = null;
3
+
4
+ /** @internal For tests only. */
5
+ export function setCompiledExecutableOverride(value: boolean | null): void {
6
+ compiledOverride = value;
7
+ }
8
+
9
+ /** True when running as a `bun build --compile` binary (embedded files present). */
10
+ export function isCompiledExecutable(): boolean {
11
+ if (compiledOverride !== null) {
12
+ return compiledOverride;
13
+ }
14
+ return (Bun.embeddedFiles?.length ?? 0) > 0;
15
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { CliProgram } from "../types.ts";
4
+ import { completionBashScript, completionFishScript, completionZshScript } from "../builtins/index.ts";
5
+ import { cliPresentationRoot } from "../builtins/presentation.ts";
6
+ import { InstallPaths } from "./paths.ts";
7
+ import { detectShells } from "./shell.ts";
8
+
9
+ /** Writes shell completion scripts for detected shells. */
10
+ export function installCompletions(root: CliProgram, paths: InstallPaths, dry: boolean): string[] {
11
+ const changed: string[] = [];
12
+ const shells = detectShells();
13
+ const schema = cliPresentationRoot(root);
14
+
15
+ if (shells.bash) {
16
+ if (!dry) {
17
+ mkdirSync(dirname(paths.bashCompletion), { recursive: true });
18
+ writeFileSync(paths.bashCompletion, completionBashScript(schema), "utf8");
19
+ }
20
+ changed.push(paths.bashCompletion);
21
+ }
22
+
23
+ if (shells.zsh) {
24
+ if (!dry) {
25
+ mkdirSync(dirname(paths.zshCompletion), { recursive: true });
26
+ writeFileSync(paths.zshCompletion, completionZshScript(schema), "utf8");
27
+ }
28
+ changed.push(paths.zshCompletion);
29
+ }
30
+
31
+ if (shells.fish) {
32
+ if (!dry) {
33
+ mkdirSync(dirname(paths.fishCompletion), { recursive: true });
34
+ writeFileSync(paths.fishCompletion, completionFishScript(schema), "utf8");
35
+ }
36
+ changed.push(paths.fishCompletion);
37
+ }
38
+
39
+ return changed;
40
+ }
41
+
42
+ /** Removes shell completion files. */
43
+ export function uninstallCompletions(paths: InstallPaths, dry: boolean): string[] {
44
+ const changed: string[] = [];
45
+ for (const p of [paths.bashCompletion, paths.zshCompletion, paths.fishCompletion]) {
46
+ if (existsSync(p)) {
47
+ if (!dry) unlinkSync(p);
48
+ changed.push(p);
49
+ }
50
+ }
51
+ return changed;
52
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { CliProgram } from "../types.ts";
3
+ import { InstallPaths } from "./paths.ts";
4
+
5
+ export interface InstalledArtifacts {
6
+ binary: boolean;
7
+ bashCompletion: boolean;
8
+ zshCompletion: boolean;
9
+ fishCompletion: boolean;
10
+ cursorSkill: boolean;
11
+ claudeSkill: boolean;
12
+ cursorMcp: boolean;
13
+ claudeMcp: boolean;
14
+ bashRcPath: boolean;
15
+ zshRcFpath: boolean;
16
+ }
17
+
18
+ function mcpConfigHasServer(path: string, name: string): boolean {
19
+ if (!existsSync(path)) return false;
20
+ try {
21
+ const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, unknown> };
22
+ return data.mcpServers?.[name] !== undefined;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** Detects which install artifacts are currently present. */
29
+ export function detectInstalledArtifacts(paths: InstallPaths): InstalledArtifacts {
30
+ return {
31
+ binary: existsSync(paths.binaryPath),
32
+ bashCompletion: existsSync(paths.bashCompletion),
33
+ zshCompletion: existsSync(paths.zshCompletion),
34
+ fishCompletion: existsSync(paths.fishCompletion),
35
+ cursorSkill: existsSync(paths.cursorSkillDir),
36
+ claudeSkill: existsSync(paths.claudeSkillDir),
37
+ cursorMcp: mcpConfigHasServer(paths.cursorMcpPath, paths.mcpName),
38
+ claudeMcp: mcpConfigHasServer(paths.claudeMcpPath, paths.mcpName),
39
+ bashRcPath: false,
40
+ zshRcFpath: false,
41
+ };
42
+ }
43
+
44
+ export interface InstallStatus {
45
+ binary?: string;
46
+ bashCompletion?: string;
47
+ zshCompletion?: string;
48
+ fishCompletion?: string;
49
+ cursorSkill?: string;
50
+ claudeSkill?: string;
51
+ cursorMcp?: string;
52
+ claudeMcp?: string;
53
+ }
54
+
55
+ /** Builds a status inventory from detected artifacts. */
56
+ export function buildInstallStatus(paths: InstallPaths, detected: InstalledArtifacts): InstallStatus {
57
+ const status: InstallStatus = {};
58
+ if (detected.binary) status.binary = paths.binaryPath;
59
+ if (detected.bashCompletion) status.bashCompletion = paths.bashCompletion;
60
+ if (detected.zshCompletion) status.zshCompletion = paths.zshCompletion;
61
+ if (detected.fishCompletion) status.fishCompletion = paths.fishCompletion;
62
+ if (detected.cursorSkill) status.cursorSkill = paths.cursorSkillDir + "/";
63
+ if (detected.claudeSkill) status.claudeSkill = paths.claudeSkillDir + "/";
64
+ if (detected.cursorMcp) status.cursorMcp = `${paths.cursorMcpPath} (server "${paths.mcpName}")`;
65
+ if (detected.claudeMcp) status.claudeMcp = `${paths.claudeMcpPath} (server "${paths.mcpName}")`;
66
+ return status;
67
+ }
@@ -0,0 +1,196 @@
1
+ import { readSync } from "node:fs";
2
+ import { CliProgram } from "../types.ts";
3
+ import { cliSkillInstall } from "../skill/install.ts";
4
+ import { checkMcpConflict, expectedMcpEntry } from "./mcp-config.ts";
5
+ import {
6
+ buildInstallPlan,
7
+ buildUpdatePlan,
8
+ type InstallAction,
9
+ type InstallOpts,
10
+ } from "./plan.ts";
11
+ import { resolveInstallPaths } from "./paths.ts";
12
+ import { installErr, installInfo, installOut, printInstallStatus } from "./status.ts";
13
+ import { buildUninstallPlan, uninstallSkillDir, type UninstallAction } from "./uninstall.ts";
14
+
15
+ function parseInstallOpts(raw: Record<string, string>): InstallOpts {
16
+ const flag = (name: string) => raw[name] === "1";
17
+ return {
18
+ all: flag("all"),
19
+ bin: flag("bin"),
20
+ completions: flag("completions"),
21
+ skill: flag("skill"),
22
+ mcp: flag("mcp"),
23
+ update: flag("update"),
24
+ status: flag("status"),
25
+ uninstall: flag("uninstall"),
26
+ yes: flag("yes"),
27
+ dry: flag("dry"),
28
+ json: flag("json"),
29
+ quiet: flag("quiet"),
30
+ prefix: raw.prefix,
31
+ };
32
+ }
33
+
34
+ function validateOpts(opts: InstallOpts): string | null {
35
+ if (opts.quiet && opts.dry) {
36
+ return "--quiet cannot be combined with --dry.";
37
+ }
38
+ if (opts.quiet && !opts.yes && !opts.json && !opts.update) {
39
+ return "--quiet requires --yes (or --json / --update).";
40
+ }
41
+ if (opts.json) {
42
+ opts.yes = true;
43
+ }
44
+ if (opts.update) {
45
+ opts.bin = true;
46
+ opts.yes = true;
47
+ }
48
+
49
+ const mutationFlags = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.update || opts.uninstall;
50
+ if (opts.status && mutationFlags) {
51
+ return "--status is mutually exclusive with install/update/uninstall targets.";
52
+ }
53
+ if (opts.update && (opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.uninstall || opts.status)) {
54
+ return "--update cannot be combined with other target flags.";
55
+ }
56
+ if (opts.uninstall && (opts.all || opts.update || opts.status)) {
57
+ return "--uninstall cannot be combined with --all, --update, or --status.";
58
+ }
59
+ if (!opts.status && !opts.update && !opts.uninstall) {
60
+ const hasTarget = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp;
61
+ if (!hasTarget) {
62
+ return "Specify at least one target: --all, --bin, --completions, --skill, or --mcp.";
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function promptConfirm(): boolean {
69
+ process.stderr.write("Continue? [y/N] ");
70
+ const buf = Buffer.alloc(256);
71
+ const n = readSync(0, buf, { length: 256 });
72
+ const ans = buf.toString("utf8", 0, n).trim();
73
+ return ans === "y" || ans === "Y";
74
+ }
75
+
76
+ function runSkillAction(
77
+ root: CliProgram,
78
+ kind: "cursor-skill" | "claude-skill",
79
+ opts: InstallOpts,
80
+ ): string[] {
81
+ const target = kind === "cursor-skill" ? "cursor" : "claude";
82
+ return cliSkillInstall(root, target, {
83
+ global: true,
84
+ rimraf: true,
85
+ dry: opts.dry,
86
+ });
87
+ }
88
+
89
+ function executePlan(
90
+ root: CliProgram,
91
+ actions: Array<InstallAction | UninstallAction>,
92
+ opts: InstallOpts,
93
+ ): string[] {
94
+ const changed: string[] = [];
95
+ for (const action of actions) {
96
+ installInfo(action.message, opts);
97
+ if ("kind" in action && (action.kind === "cursor-skill" || action.kind === "claude-skill")) {
98
+ changed.push(...runSkillAction(root, action.kind, opts));
99
+ continue;
100
+ }
101
+ if (!("kind" in action) && action.summary.startsWith("cursor skill")) {
102
+ const paths = resolveInstallPaths(root, opts);
103
+ changed.push(...uninstallSkillDir(paths.cursorSkillDir, !!opts.dry));
104
+ continue;
105
+ }
106
+ if (!("kind" in action) && action.summary.startsWith("claude skill")) {
107
+ const paths = resolveInstallPaths(root, opts);
108
+ changed.push(...uninstallSkillDir(paths.claudeSkillDir, !!opts.dry));
109
+ continue;
110
+ }
111
+ changed.push(...action.run());
112
+ }
113
+ return changed;
114
+ }
115
+
116
+ /** Main install command orchestrator. */
117
+ export async function cliInstall(root: CliProgram, rawOpts: Record<string, string>): Promise<never> {
118
+ const opts = parseInstallOpts(rawOpts);
119
+ const err = validateOpts(opts);
120
+ if (err) {
121
+ installErr(err);
122
+ process.exit(1);
123
+ }
124
+
125
+ const paths = resolveInstallPaths(root, opts);
126
+
127
+ if (opts.status) {
128
+ printInstallStatus(root, opts);
129
+ process.exit(0);
130
+ }
131
+
132
+ // MCP conflict checks before planning
133
+ if (!opts.uninstall && root.mcpServer && (opts.all || opts.mcp)) {
134
+ const entry = expectedMcpEntry(root);
135
+ const yes = !!opts.yes;
136
+ for (const p of [paths.cursorMcpPath, paths.claudeMcpPath]) {
137
+ const conflict = checkMcpConflict(p, paths.mcpName, entry, yes);
138
+ if (conflict) {
139
+ installErr(conflict);
140
+ process.exit(1);
141
+ }
142
+ }
143
+ }
144
+
145
+ let actions: Array<InstallAction | UninstallAction>;
146
+ if (opts.uninstall) {
147
+ actions = buildUninstallPlan(root, paths, opts);
148
+ } else if (opts.update) {
149
+ actions = buildUpdatePlan(root, paths, opts);
150
+ } else {
151
+ actions = buildInstallPlan(root, paths, opts);
152
+ }
153
+
154
+ if (actions.length === 0) {
155
+ installErr("Nothing to do.");
156
+ process.exit(1);
157
+ }
158
+
159
+ if (!opts.quiet && !opts.json) {
160
+ installOut("About to " + (opts.uninstall ? "remove" : opts.update ? "update" : "install") + ":", opts);
161
+ for (const a of actions) {
162
+ installOut(" - " + a.summary, opts);
163
+ }
164
+ }
165
+
166
+ const autoYes = opts.yes || opts.json || opts.update;
167
+ if (!autoYes) {
168
+ if (!process.stdin.isTTY) {
169
+ installErr("Refusing to proceed without --yes (stdin is not a TTY).");
170
+ process.exit(1);
171
+ }
172
+ if (!promptConfirm()) {
173
+ installErr("Aborted.");
174
+ process.exit(1);
175
+ }
176
+ }
177
+
178
+ const changed = executePlan(root, actions, opts);
179
+
180
+ if (opts.json) {
181
+ process.stdout.write(JSON.stringify(changed, null, 2) + "\n");
182
+ process.exit(0);
183
+ }
184
+
185
+ if (!opts.quiet && changed.length > 0) {
186
+ const verb = opts.uninstall ? "Removed" : opts.update ? "Updated" : "Installed";
187
+ installOut(`${verb} ${changed.length} file(s).`, opts);
188
+ if (!opts.uninstall && (opts.all || opts.bin) && changed.some((p) => p === paths.bashRc || p === paths.zshRc || p === paths.binaryPath)) {
189
+ installOut("Open a new shell, or run: hash -r (bash) / rehash (zsh)", opts);
190
+ }
191
+ }
192
+
193
+ process.exit(0);
194
+ }
195
+
196
+ export { parseInstallOpts };
@@ -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 { CliProgram } 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: CliProgram = {
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 { CliProgram } 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: CliProgram): 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 { CliProgram } 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: CliProgram, 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: CliProgram, 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
+ }