argsbarg 1.4.3 → 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.
- package/.private/scratch.md +1 -1
- package/CHANGELOG.md +16 -1
- package/README.md +17 -13
- package/docs/ai-skills.md +24 -52
- package/docs/install.md +84 -0
- package/docs/mcp.md +5 -5
- package/index.d.ts +9 -18
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +101 -0
- package/src/builtins/completion-bash.ts +240 -0
- package/src/builtins/completion-fish.ts +73 -0
- package/src/builtins/completion-group.ts +50 -0
- package/src/builtins/completion-zsh.ts +244 -0
- package/src/builtins/dispatch.ts +123 -0
- package/src/builtins/export.ts +46 -0
- package/src/builtins/index.ts +10 -0
- package/src/builtins/install.ts +99 -0
- package/src/builtins/mcp.ts +13 -0
- package/src/builtins/presentation.ts +39 -0
- package/src/builtins/scopes.ts +45 -0
- package/src/builtins/shell-helpers.ts +24 -0
- package/src/completion.ts +10 -693
- package/src/index.test.ts +44 -55
- package/src/index.ts +1 -0
- package/src/install/binary.ts +82 -0
- package/src/install/compiled.ts +15 -0
- package/src/install/completions.ts +52 -0
- package/src/install/detect-installed.ts +67 -0
- package/src/install/index.ts +196 -0
- package/src/install/install.test.ts +124 -0
- package/src/install/mcp-config.ts +70 -0
- package/src/install/paths.ts +69 -0
- package/src/install/plan.ts +183 -0
- package/src/install/shell.ts +56 -0
- package/src/install/status.ts +63 -0
- package/src/install/uninstall.ts +111 -0
- package/src/mcp/tools.ts +1 -1
- package/src/runtime.ts +21 -83
- package/src/schema.ts +7 -49
- package/src/skill/generate.ts +4 -4
- package/src/skill/install.ts +20 -18
- package/src/types.ts +9 -9
- package/src/validate.ts +10 -22
- package/src/ai.ts +0 -7
package/src/index.test.ts
CHANGED
|
@@ -729,7 +729,7 @@ async function mcpRequest(
|
|
|
729
729
|
opts?: { script?: string; env?: Record<string, string> },
|
|
730
730
|
): Promise<Map<string | number, object>> {
|
|
731
731
|
const script = opts?.script ?? "examples/nested.ts";
|
|
732
|
-
const proc = Bun.spawn(["bun", "run", script, "
|
|
732
|
+
const proc = Bun.spawn(["bun", "run", script, "mcp"], {
|
|
733
733
|
stdin: "pipe",
|
|
734
734
|
stdout: "pipe",
|
|
735
735
|
stderr: "pipe",
|
|
@@ -777,7 +777,8 @@ test("collectMcpTools lists user leaf commands only", () => {
|
|
|
777
777
|
expect(names).toContain("stat_owner_lookup");
|
|
778
778
|
expect(names).toContain("read");
|
|
779
779
|
expect(names).not.toContain("hidden");
|
|
780
|
-
expect(names).not.toContain("
|
|
780
|
+
expect(names).not.toContain("install");
|
|
781
|
+
expect(names).not.toContain("mcp");
|
|
781
782
|
expect(names).not.toContain("completion");
|
|
782
783
|
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
783
784
|
expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
|
|
@@ -809,22 +810,22 @@ test("mcpToolCallToArgv expands varargs positionals", () => {
|
|
|
809
810
|
expect(argv).toEqual(["read", "a", "b"]);
|
|
810
811
|
});
|
|
811
812
|
|
|
812
|
-
test("reserved command name
|
|
813
|
+
test("reserved command name install is rejected", () => {
|
|
813
814
|
const root: CliCommand = {
|
|
814
815
|
key: "app",
|
|
815
816
|
description: "",
|
|
816
817
|
commands: [
|
|
817
818
|
{
|
|
818
|
-
key: "
|
|
819
|
+
key: "install",
|
|
819
820
|
description: "bad",
|
|
820
821
|
handler: () => {},
|
|
821
822
|
},
|
|
822
823
|
],
|
|
823
824
|
};
|
|
824
|
-
expect(() => cliValidateRoot(root)).toThrow(/Reserved command name:
|
|
825
|
+
expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: install/);
|
|
825
826
|
});
|
|
826
827
|
|
|
827
|
-
test("top-level command name mcp is allowed", () => {
|
|
828
|
+
test("top-level command name mcp is allowed without mcpServer", () => {
|
|
828
829
|
const root: CliCommand = {
|
|
829
830
|
key: "app",
|
|
830
831
|
description: "",
|
|
@@ -839,6 +840,22 @@ test("top-level command name mcp is allowed", () => {
|
|
|
839
840
|
expect(() => cliValidateRoot(root)).not.toThrow();
|
|
840
841
|
});
|
|
841
842
|
|
|
843
|
+
test("top-level command name mcp is rejected when mcpServer is set", () => {
|
|
844
|
+
const root: CliCommand = {
|
|
845
|
+
key: "app",
|
|
846
|
+
description: "",
|
|
847
|
+
mcpServer: {},
|
|
848
|
+
commands: [
|
|
849
|
+
{
|
|
850
|
+
key: "mcp",
|
|
851
|
+
description: "user command",
|
|
852
|
+
handler: () => {},
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
};
|
|
856
|
+
expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: mcp/);
|
|
857
|
+
});
|
|
858
|
+
|
|
842
859
|
test("mcpServer on non-root node is rejected", () => {
|
|
843
860
|
const root: CliCommand = {
|
|
844
861
|
key: "app",
|
|
@@ -1014,8 +1031,8 @@ test("MCP ping returns empty result", async () => {
|
|
|
1014
1031
|
expect(res.result).toEqual({});
|
|
1015
1032
|
});
|
|
1016
1033
|
|
|
1017
|
-
test("minimal.ts
|
|
1018
|
-
const { stderr, exitCode } = await $`bun run examples/minimal.ts
|
|
1034
|
+
test("minimal.ts mcp without opt-in fails", async () => {
|
|
1035
|
+
const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
|
|
1019
1036
|
expect(exitCode).toBe(1);
|
|
1020
1037
|
expect(stderr.toString()).toContain("MCP is not enabled");
|
|
1021
1038
|
});
|
|
@@ -1653,9 +1670,9 @@ test("mcpToolCallToArgv empty string varargs appends nothing", () => {
|
|
|
1653
1670
|
expect(argv).toEqual(["read"]);
|
|
1654
1671
|
});
|
|
1655
1672
|
|
|
1656
|
-
// ──
|
|
1673
|
+
// ── Skills ────────────────────────────────────────────────────────────────────
|
|
1657
1674
|
|
|
1658
|
-
test("
|
|
1675
|
+
test("install config on non-root node is rejected", () => {
|
|
1659
1676
|
const root: CliCommand = {
|
|
1660
1677
|
key: "app",
|
|
1661
1678
|
description: "",
|
|
@@ -1663,12 +1680,12 @@ test("aiSkill on non-root node is rejected", () => {
|
|
|
1663
1680
|
{
|
|
1664
1681
|
key: "x",
|
|
1665
1682
|
description: "",
|
|
1666
|
-
|
|
1683
|
+
install: { enabled: false },
|
|
1667
1684
|
handler: () => {},
|
|
1668
1685
|
},
|
|
1669
1686
|
],
|
|
1670
1687
|
};
|
|
1671
|
-
expect(() => cliValidateRoot(root)).toThrow(/
|
|
1688
|
+
expect(() => cliValidateRoot(root)).toThrow(/install is only supported on the program root/);
|
|
1672
1689
|
});
|
|
1673
1690
|
|
|
1674
1691
|
test("generateSkillBundle includes frontmatter and command catalog", () => {
|
|
@@ -1676,7 +1693,7 @@ test("generateSkillBundle includes frontmatter and command catalog", () => {
|
|
|
1676
1693
|
expect(bundle.dirName).toBe("nested_ts");
|
|
1677
1694
|
expect(bundle.skillMd).toMatch(/^---\nname: nested_ts\n/);
|
|
1678
1695
|
expect(bundle.skillMd).toContain("stat owner lookup");
|
|
1679
|
-
expect(bundle.skillMd).toContain("
|
|
1696
|
+
expect(bundle.skillMd).toContain("nested.ts mcp");
|
|
1680
1697
|
expect(bundle.referenceMd).toContain("```json");
|
|
1681
1698
|
expect(() => JSON.parse(bundle.referenceMd.match(/```json\n([\s\S]*?)\n```/)![1]!)).not.toThrow();
|
|
1682
1699
|
});
|
|
@@ -1686,8 +1703,8 @@ test("cliSkillInstall writes project Cursor skill files", () => {
|
|
|
1686
1703
|
const prev = process.cwd();
|
|
1687
1704
|
process.chdir(cwd);
|
|
1688
1705
|
try {
|
|
1689
|
-
const
|
|
1690
|
-
expect(
|
|
1706
|
+
const files = cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
|
|
1707
|
+
expect(files.some((f) => f.includes(".cursor/skills/nested_ts/"))).toBe(true);
|
|
1691
1708
|
const skillDir = join(cwd, ".cursor", "skills", "nested_ts");
|
|
1692
1709
|
expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
|
|
1693
1710
|
expect(existsSync(join(skillDir, "reference.md"))).toBe(true);
|
|
@@ -1703,8 +1720,8 @@ test("cliSkillInstall global uses HOME skills directory", () => {
|
|
|
1703
1720
|
const prevHome = process.env.HOME;
|
|
1704
1721
|
process.env.HOME = home;
|
|
1705
1722
|
try {
|
|
1706
|
-
const
|
|
1707
|
-
expect(
|
|
1723
|
+
const files = cliSkillInstall(nestedMcpFixture, "cursor", { global: true, rimraf: true });
|
|
1724
|
+
expect(files.some((f) => f.includes(join(home, ".cursor", "skills", "nested_ts")))).toBe(true);
|
|
1708
1725
|
expect(existsSync(join(home, ".cursor", "skills", "nested_ts", "SKILL.md"))).toBe(true);
|
|
1709
1726
|
} finally {
|
|
1710
1727
|
if (prevHome === undefined) {
|
|
@@ -1716,26 +1733,19 @@ test("cliSkillInstall global uses HOME skills directory", () => {
|
|
|
1716
1733
|
}
|
|
1717
1734
|
});
|
|
1718
1735
|
|
|
1719
|
-
test("cliSkillInstall
|
|
1736
|
+
test("cliSkillInstall rimraf overwrites existing directory", () => {
|
|
1720
1737
|
const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-dup-"));
|
|
1721
1738
|
const prev = process.cwd();
|
|
1722
1739
|
process.chdir(cwd);
|
|
1723
|
-
const prevExit = process.exit;
|
|
1724
|
-
let exitCode = 0;
|
|
1725
|
-
process.exit = ((code?: number) => {
|
|
1726
|
-
exitCode = code ?? 0;
|
|
1727
|
-
throw new Error("exit");
|
|
1728
|
-
}) as typeof process.exit;
|
|
1729
1740
|
try {
|
|
1730
|
-
cliSkillInstall(nestedMcpFixture, "cursor", {
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1741
|
+
cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
|
|
1742
|
+
writeFileSync(join(cwd, ".cursor", "skills", "nested_ts", "SKILL.md"), "stale", "utf8");
|
|
1743
|
+
const files = cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
|
|
1744
|
+
expect(files.length).toBeGreaterThan(0);
|
|
1745
|
+
expect(readFileSync(join(cwd, ".cursor", "skills", "nested_ts", "SKILL.md"), "utf8")).toContain(
|
|
1746
|
+
"stat owner lookup",
|
|
1747
|
+
);
|
|
1737
1748
|
} finally {
|
|
1738
|
-
process.exit = prevExit;
|
|
1739
1749
|
process.chdir(prev);
|
|
1740
1750
|
rmSync(cwd, { recursive: true, force: true });
|
|
1741
1751
|
}
|
|
@@ -1746,8 +1756,8 @@ test("cliSkillInstall claude target uses .claude/skills", () => {
|
|
|
1746
1756
|
const prev = process.cwd();
|
|
1747
1757
|
process.chdir(cwd);
|
|
1748
1758
|
try {
|
|
1749
|
-
const
|
|
1750
|
-
expect(
|
|
1759
|
+
const files = cliSkillInstall(nestedMcpFixture, "claude", { rimraf: true });
|
|
1760
|
+
expect(files.some((f) => f.includes(".claude/skills/nested_ts/"))).toBe(true);
|
|
1751
1761
|
expect(readFileSync(join(cwd, ".claude", "skills", "nested_ts", "SKILL.md"), "utf8")).toContain(
|
|
1752
1762
|
"Claude Code",
|
|
1753
1763
|
);
|
|
@@ -1755,25 +1765,4 @@ test("cliSkillInstall claude target uses .claude/skills", () => {
|
|
|
1755
1765
|
process.chdir(prev);
|
|
1756
1766
|
rmSync(cwd, { recursive: true, force: true });
|
|
1757
1767
|
}
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
test("ai skill cursor fails when aiSkill disabled", async () => {
|
|
1761
|
-
const dir = mkdtempSync(join(tmpdir(), "argsbarg-skill-off-"));
|
|
1762
|
-
const script = join(dir, "skill-off.ts");
|
|
1763
|
-
writeFileSync(
|
|
1764
|
-
script,
|
|
1765
|
-
`import { cliRun, CliCommand } from ${JSON.stringify(join(import.meta.dir, "index.ts"))};
|
|
1766
|
-
const cli: CliCommand = {
|
|
1767
|
-
key: "offapp",
|
|
1768
|
-
description: "demo",
|
|
1769
|
-
aiSkill: { enabled: false },
|
|
1770
|
-
commands: [{ key: "x", description: "x", handler: () => {} }],
|
|
1771
|
-
};
|
|
1772
|
-
await cliRun(cli);
|
|
1773
|
-
`,
|
|
1774
|
-
"utf8",
|
|
1775
|
-
);
|
|
1776
|
-
const { stderr, exitCode } = await $`bun run ${script} ai skill cursor`.nothrow().quiet();
|
|
1777
|
-
expect(exitCode).toBe(1);
|
|
1778
|
-
expect(stderr.toString()).toContain("AI skills are disabled");
|
|
1779
1768
|
});
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CliCommand } 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: CliCommand, 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: CliCommand, 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 { CliCommand } 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: CliCommand, 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 { CliCommand } 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 { CliCommand } 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: CliCommand,
|
|
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: CliCommand,
|
|
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: CliCommand, 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 };
|