@serranolabs.io/munchkins 0.1.1 → 0.1.3
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/agents/_shared/presets.ts +9 -1
- package/agents/bugfix/bugfix-agent.ts +3 -5
- package/agents/director/director-agent.test.ts +149 -0
- package/agents/director/director-agent.ts +45 -0
- package/agents/director/prompts/plan.md +81 -0
- package/agents/director/prompts/spec.md +65 -0
- package/agents/director/prompts/triage.md +49 -0
- package/agents/director/scripts/dispatch.sh +63 -0
- package/agents/director/scripts/inflight-survey.sh +85 -0
- package/agents/director/scripts/repo-survey.sh +63 -0
- package/agents/feat-small/feat-small-agent.ts +3 -1
- package/agents/refactor/refactor-agent.ts +3 -1
- package/package.json +7 -3
- package/skills/director/SKILL.md +54 -0
- package/{agents/bugfix/prompts/bug-fix.md → skills/munchkins-bug-fix/SKILL.md} +5 -0
- package/{agents/feat-small/prompts/feat-small.md → skills/munchkins-feat-small/SKILL.md} +5 -0
- package/skills/{launch-munchkin → munchkins-launch-munchkin}/SKILL.md +1 -1
- package/skills/munchkins-new-munchkin/SKILL.md +646 -0
- package/{agents/refactor/prompts/refactor.md → skills/munchkins-refactor/SKILL.md} +5 -0
- package/src/cmux-launcher.test.ts +158 -0
- package/src/cmux-launcher.ts +70 -0
- package/src/index.ts +27 -6
- package/src/skills-install.test.ts +186 -0
- package/src/skills-install.ts +190 -19
- package/skills/new-munchkin/SKILL.md +0 -343
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: munchkins:refactor
|
|
3
|
+
description: Behavior-preserving refactor of a target via the munchkins refactor agent — runs in a fresh worktree, applies DRY/clarity changes inside the named scope, gates with lint/typecheck/scenario, then merges or opens a PR. Use when the user wants refactoring done via the deterministic agent rather than inline editing.
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# refactor subagent
|
|
2
7
|
|
|
3
8
|
You are the refactor subagent. The user prompt contains a description of what to refactor in this repository — a target (file, directory, module) and a smell or improvement goal.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCmuxCommand, shouldDelegateToCmux } from "./cmux-launcher.js";
|
|
3
|
+
|
|
4
|
+
const NORMAL_ARGV = ["bun", "/abs/index.ts", "bug-fix", "--user-message=./bug.md"];
|
|
5
|
+
|
|
6
|
+
describe("shouldDelegateToCmux", () => {
|
|
7
|
+
test("returns false when hasCmux is false even with otherwise valid inputs", () => {
|
|
8
|
+
expect(
|
|
9
|
+
shouldDelegateToCmux({
|
|
10
|
+
argv: NORMAL_ARGV,
|
|
11
|
+
env: {},
|
|
12
|
+
hasCmux: false,
|
|
13
|
+
}),
|
|
14
|
+
).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("returns false when MUNCHKINS_NO_CMUX=1", () => {
|
|
18
|
+
expect(
|
|
19
|
+
shouldDelegateToCmux({
|
|
20
|
+
argv: NORMAL_ARGV,
|
|
21
|
+
env: { MUNCHKINS_NO_CMUX: "1" },
|
|
22
|
+
hasCmux: true,
|
|
23
|
+
}),
|
|
24
|
+
).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns false when --no-cmux is in argv", () => {
|
|
28
|
+
expect(
|
|
29
|
+
shouldDelegateToCmux({
|
|
30
|
+
argv: [...NORMAL_ARGV, "--no-cmux"],
|
|
31
|
+
env: {},
|
|
32
|
+
hasCmux: true,
|
|
33
|
+
}),
|
|
34
|
+
).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test.each([
|
|
38
|
+
["--help"],
|
|
39
|
+
["-h"],
|
|
40
|
+
["--version"],
|
|
41
|
+
["-v"],
|
|
42
|
+
["--dry-run"],
|
|
43
|
+
])("returns false when %s is in argv", (flag) => {
|
|
44
|
+
expect(
|
|
45
|
+
shouldDelegateToCmux({
|
|
46
|
+
argv: ["bun", "/abs/index.ts", "bug-fix", flag],
|
|
47
|
+
env: {},
|
|
48
|
+
hasCmux: true,
|
|
49
|
+
}),
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns false when argv[2] is missing", () => {
|
|
54
|
+
expect(
|
|
55
|
+
shouldDelegateToCmux({
|
|
56
|
+
argv: ["bun", "/abs/index.ts"],
|
|
57
|
+
env: {},
|
|
58
|
+
hasCmux: true,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns false when argv[2] starts with '-'", () => {
|
|
64
|
+
expect(
|
|
65
|
+
shouldDelegateToCmux({
|
|
66
|
+
argv: ["bun", "/abs/index.ts", "--something"],
|
|
67
|
+
env: {},
|
|
68
|
+
hasCmux: true,
|
|
69
|
+
}),
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test.each([
|
|
74
|
+
["daemon"],
|
|
75
|
+
["resume"],
|
|
76
|
+
["status"],
|
|
77
|
+
["skills"],
|
|
78
|
+
])("returns false when argv[2] is meta-subcommand %s", (sub) => {
|
|
79
|
+
expect(
|
|
80
|
+
shouldDelegateToCmux({
|
|
81
|
+
argv: ["bun", "/abs/index.ts", sub],
|
|
82
|
+
env: {},
|
|
83
|
+
hasCmux: true,
|
|
84
|
+
}),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns true for normal agent invocation with cmux installed and clean env", () => {
|
|
89
|
+
expect(
|
|
90
|
+
shouldDelegateToCmux({
|
|
91
|
+
argv: NORMAL_ARGV,
|
|
92
|
+
env: {},
|
|
93
|
+
hasCmux: true,
|
|
94
|
+
}),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function getInnerCommand(command: string[]): string {
|
|
100
|
+
return command[command.indexOf("--command") + 1];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("buildCmuxCommand", () => {
|
|
104
|
+
test("formats workspace name as <agent>-<now>", () => {
|
|
105
|
+
const { workspaceName } = buildCmuxCommand({
|
|
106
|
+
argv: NORMAL_ARGV,
|
|
107
|
+
cwd: "/repo",
|
|
108
|
+
now: 1700000000000,
|
|
109
|
+
});
|
|
110
|
+
expect(workspaceName).toBe("bug-fix-1700000000000");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("includes --cwd from input", () => {
|
|
114
|
+
const { command } = buildCmuxCommand({
|
|
115
|
+
argv: NORMAL_ARGV,
|
|
116
|
+
cwd: "/some/where",
|
|
117
|
+
now: 1,
|
|
118
|
+
});
|
|
119
|
+
const cwdIdx = command.indexOf("--cwd");
|
|
120
|
+
expect(cwdIdx).toBeGreaterThan(-1);
|
|
121
|
+
expect(command[cwdIdx + 1]).toBe("/some/where");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("--command starts with MUNCHKINS_NO_CMUX=1, single-quotes every argv element, uses argv[1] absolute script path", () => {
|
|
125
|
+
const { command } = buildCmuxCommand({
|
|
126
|
+
argv: ["bun", "/abs/index.ts", "bug-fix", "--user-message=./bug.md"],
|
|
127
|
+
cwd: "/repo",
|
|
128
|
+
now: 42,
|
|
129
|
+
});
|
|
130
|
+
const inner = getInnerCommand(command);
|
|
131
|
+
expect(inner.startsWith("MUNCHKINS_NO_CMUX=1 ")).toBe(true);
|
|
132
|
+
expect(inner).toBe(
|
|
133
|
+
"MUNCHKINS_NO_CMUX=1 'bun' 'run' '/abs/index.ts' 'bug-fix' '--user-message=./bug.md'",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("strips --no-cmux from inner command", () => {
|
|
138
|
+
const { command } = buildCmuxCommand({
|
|
139
|
+
argv: ["bun", "/abs/index.ts", "bug-fix", "--no-cmux", "--user-message=./bug.md"],
|
|
140
|
+
cwd: "/repo",
|
|
141
|
+
now: 1,
|
|
142
|
+
});
|
|
143
|
+
const inner = getInnerCommand(command);
|
|
144
|
+
expect(inner).not.toContain("--no-cmux");
|
|
145
|
+
expect(inner).toContain("'bug-fix'");
|
|
146
|
+
expect(inner).toContain("'--user-message=./bug.md'");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("escapes literal single quotes using POSIX '\\'' wrap", () => {
|
|
150
|
+
const { command } = buildCmuxCommand({
|
|
151
|
+
argv: ["bun", "/abs/index.ts", "bug-fix", "--user-message=can't stop"],
|
|
152
|
+
cwd: "/repo",
|
|
153
|
+
now: 1,
|
|
154
|
+
});
|
|
155
|
+
const inner = getInnerCommand(command);
|
|
156
|
+
expect(inner).toContain("'--user-message=can'\\''t stop'");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const NON_AGENT_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
2
|
+
"daemon",
|
|
3
|
+
"resume",
|
|
4
|
+
"status",
|
|
5
|
+
"skills",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const ARGV_SKIP_FLAGS: ReadonlySet<string> = new Set([
|
|
9
|
+
"--no-cmux",
|
|
10
|
+
"--help",
|
|
11
|
+
"-h",
|
|
12
|
+
"--version",
|
|
13
|
+
"-v",
|
|
14
|
+
"--dry-run",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
interface DelegateDecisionInput {
|
|
18
|
+
argv: readonly string[];
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
hasCmux: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function shouldDelegateToCmux(input: DelegateDecisionInput): boolean {
|
|
24
|
+
if (!input.hasCmux) return false;
|
|
25
|
+
if (input.env.MUNCHKINS_NO_CMUX === "1") return false;
|
|
26
|
+
for (const arg of input.argv) {
|
|
27
|
+
if (ARGV_SKIP_FLAGS.has(arg)) return false;
|
|
28
|
+
}
|
|
29
|
+
const sub = input.argv[2];
|
|
30
|
+
if (!sub) return false;
|
|
31
|
+
if (sub.startsWith("-")) return false;
|
|
32
|
+
if (NON_AGENT_SUBCOMMANDS.has(sub)) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BuildCmuxCommandInput {
|
|
37
|
+
argv: readonly string[];
|
|
38
|
+
cwd: string;
|
|
39
|
+
now: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CmuxCommand {
|
|
43
|
+
command: string[];
|
|
44
|
+
workspaceName: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildCmuxCommand(input: BuildCmuxCommandInput): CmuxCommand {
|
|
48
|
+
const agentName = input.argv[2];
|
|
49
|
+
const workspaceName = `${agentName}-${input.now}`;
|
|
50
|
+
const innerArgs = input.argv.slice(2).filter((a) => a !== "--no-cmux");
|
|
51
|
+
const inner = ["bun", "run", input.argv[1], ...innerArgs];
|
|
52
|
+
const innerCommand = `MUNCHKINS_NO_CMUX=1 ${inner.map(shellEscape).join(" ")}`;
|
|
53
|
+
return {
|
|
54
|
+
command: [
|
|
55
|
+
"cmux",
|
|
56
|
+
"new-workspace",
|
|
57
|
+
"--name",
|
|
58
|
+
workspaceName,
|
|
59
|
+
"--cwd",
|
|
60
|
+
input.cwd,
|
|
61
|
+
"--command",
|
|
62
|
+
innerCommand,
|
|
63
|
+
],
|
|
64
|
+
workspaceName,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shellEscape(value: string): string {
|
|
69
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
70
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
1
2
|
export * from "@serranolabs.io/munchkins-core";
|
|
2
3
|
import "../agents/bugfix/bugfix-agent.js";
|
|
4
|
+
import "../agents/director/director-agent.js";
|
|
3
5
|
import "../agents/feat-small/feat-small-agent.js";
|
|
4
6
|
import "../agents/refactor/refactor-agent.js";
|
|
7
|
+
import { buildCmuxCommand, shouldDelegateToCmux } from "./cmux-launcher.js";
|
|
5
8
|
|
|
6
9
|
if (import.meta.main) {
|
|
10
|
+
const hasCmux = Bun.which("cmux") !== null;
|
|
11
|
+
if (shouldDelegateToCmux({ argv: process.argv, env: process.env, hasCmux })) {
|
|
12
|
+
const { command, workspaceName } = buildCmuxCommand({
|
|
13
|
+
argv: process.argv,
|
|
14
|
+
cwd: process.cwd(),
|
|
15
|
+
now: Date.now(),
|
|
16
|
+
});
|
|
17
|
+
const agentName = process.argv[2];
|
|
18
|
+
process.stdout.write(`Launching ${agentName} in cmux workspace: ${workspaceName}\n`);
|
|
19
|
+
const proc = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
|
|
20
|
+
process.exit(await proc.exited);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const argv = process.argv.filter((a) => a !== "--no-cmux");
|
|
7
24
|
const { registry } = await import("@serranolabs.io/munchkins-core");
|
|
8
|
-
if (
|
|
25
|
+
if (argv[2] === "daemon") {
|
|
9
26
|
const { runDaemon } = await import("@serranolabs.io/munchkins-core");
|
|
10
27
|
await runDaemon();
|
|
11
|
-
} else if (
|
|
28
|
+
} else if (argv[2] === "resume") {
|
|
12
29
|
const { runResume } = await import("@serranolabs.io/munchkins-core");
|
|
13
|
-
const result = await runResume(
|
|
30
|
+
const result = await runResume(argv.slice(3));
|
|
31
|
+
process.exit(result.exitCode);
|
|
32
|
+
} else if (argv[2] === "status") {
|
|
33
|
+
const { runStatus } = await import("@serranolabs.io/munchkins-core");
|
|
34
|
+
const result = await runStatus(argv.slice(3));
|
|
14
35
|
process.exit(result.exitCode);
|
|
15
|
-
} else if (
|
|
36
|
+
} else if (argv[2] === "skills" && argv[3] === "install") {
|
|
16
37
|
const { runSkillsInstall } = await import("./skills-install.js");
|
|
17
|
-
runSkillsInstall(
|
|
38
|
+
runSkillsInstall(argv.slice(4));
|
|
18
39
|
} else {
|
|
19
|
-
await registry.cli().parseAsync(
|
|
40
|
+
await registry.cli().parseAsync(argv);
|
|
20
41
|
}
|
|
21
42
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { _resolveTarget, _runSkillsInstall } from "./skills-install.js";
|
|
6
|
+
|
|
7
|
+
class ProcessExitError extends Error {
|
|
8
|
+
constructor(public code: number) {
|
|
9
|
+
super(`process.exit(${code})`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let tmp: string;
|
|
14
|
+
const stdout: string[] = [];
|
|
15
|
+
const stderr: string[] = [];
|
|
16
|
+
const originalLog = console.log;
|
|
17
|
+
const originalError = console.error;
|
|
18
|
+
const originalExit = process.exit;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmp = mkdtempSync(join(tmpdir(), "skills-install-test-"));
|
|
22
|
+
stdout.length = 0;
|
|
23
|
+
stderr.length = 0;
|
|
24
|
+
console.log = (...args: unknown[]) => {
|
|
25
|
+
stdout.push(args.map(String).join(" "));
|
|
26
|
+
};
|
|
27
|
+
console.error = (...args: unknown[]) => {
|
|
28
|
+
stderr.push(args.map(String).join(" "));
|
|
29
|
+
};
|
|
30
|
+
process.exit = ((code?: number): never => {
|
|
31
|
+
throw new ProcessExitError(code ?? 0);
|
|
32
|
+
}) as typeof process.exit;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
console.log = originalLog;
|
|
37
|
+
console.error = originalError;
|
|
38
|
+
process.exit = originalExit;
|
|
39
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function writeSkill(root: string, pkg: string, slug: string, body = `# ${slug}`): string {
|
|
43
|
+
const skillDir = join(root, "node_modules", ...pkg.split("/"), "skills", slug);
|
|
44
|
+
mkdirSync(skillDir, { recursive: true });
|
|
45
|
+
const file = join(skillDir, "SKILL.md");
|
|
46
|
+
writeFileSync(file, body);
|
|
47
|
+
return file;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function targetDir(): string {
|
|
51
|
+
return join(tmp, ".claude", "skills");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findLine(prefix: string): string {
|
|
55
|
+
return stdout.find((l) => l.startsWith(prefix)) ?? "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("_runSkillsInstall — multi-package discovery", () => {
|
|
59
|
+
test("walks every node_modules package that ships a skills/ dir", () => {
|
|
60
|
+
writeSkill(tmp, "@scope/a", "foo");
|
|
61
|
+
writeSkill(tmp, "pkg-b", "bar");
|
|
62
|
+
|
|
63
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
64
|
+
|
|
65
|
+
expect(existsSync(join(targetDir(), "foo", "SKILL.md"))).toBe(true);
|
|
66
|
+
expect(existsSync(join(targetDir(), "bar", "SKILL.md"))).toBe(true);
|
|
67
|
+
|
|
68
|
+
const installedLine = findLine("installed:");
|
|
69
|
+
expect(installedLine).toContain("foo");
|
|
70
|
+
expect(installedLine).toContain("bar");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("skips existing target files (records as kept, not installed)", () => {
|
|
74
|
+
writeSkill(tmp, "@scope/a", "foo", "from-package");
|
|
75
|
+
mkdirSync(join(targetDir(), "foo"), { recursive: true });
|
|
76
|
+
writeFileSync(join(targetDir(), "foo", "SKILL.md"), "existing");
|
|
77
|
+
|
|
78
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
79
|
+
|
|
80
|
+
expect(readFileSync(join(targetDir(), "foo", "SKILL.md"), "utf8")).toBe("existing");
|
|
81
|
+
expect(findLine("installed:")).not.toContain("foo");
|
|
82
|
+
expect(findLine("kept")).toContain("foo");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("never silently overwrites a consumer-edited file", () => {
|
|
86
|
+
writeSkill(tmp, "@scope/a", "foo", "shipped-version");
|
|
87
|
+
mkdirSync(join(targetDir(), "foo"), { recursive: true });
|
|
88
|
+
const userFile = join(targetDir(), "foo", "SKILL.md");
|
|
89
|
+
writeFileSync(userFile, "user-edit");
|
|
90
|
+
const before = readFileSync(userFile, "utf8");
|
|
91
|
+
|
|
92
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
93
|
+
|
|
94
|
+
expect(readFileSync(userFile, "utf8")).toBe(before);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("warns about slug collisions before the copy phase, first wins", () => {
|
|
98
|
+
writeSkill(tmp, "@scope/a", "bar", "from-a");
|
|
99
|
+
writeSkill(tmp, "pkg-b", "bar", "from-b");
|
|
100
|
+
|
|
101
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
102
|
+
|
|
103
|
+
const warning = findLine("⚠ slug collision");
|
|
104
|
+
expect(warning).toContain("bar");
|
|
105
|
+
expect(warning).toContain("@scope/a");
|
|
106
|
+
expect(warning).toContain("pkg-b");
|
|
107
|
+
expect(warning).toContain("first wins");
|
|
108
|
+
|
|
109
|
+
const warningIdx = stdout.indexOf(warning);
|
|
110
|
+
const installedIdx = stdout.indexOf(findLine("installed:"));
|
|
111
|
+
expect(warningIdx).toBeLessThan(installedIdx);
|
|
112
|
+
|
|
113
|
+
expect(readFileSync(join(targetDir(), "bar", "SKILL.md"), "utf8")).toBe("from-a");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("ignores a skills/ directory that contains no SKILL.md subdirs", () => {
|
|
117
|
+
mkdirSync(join(tmp, "node_modules", "noisy-pkg", "skills"), { recursive: true });
|
|
118
|
+
writeSkill(tmp, "@scope/a", "foo");
|
|
119
|
+
|
|
120
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
121
|
+
|
|
122
|
+
expect(existsSync(join(targetDir(), "foo", "SKILL.md"))).toBe(true);
|
|
123
|
+
expect(findLine("installed:")).toContain("foo");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("exits 1 with a clear error when no skills exist anywhere", () => {
|
|
127
|
+
mkdirSync(join(tmp, "node_modules"), { recursive: true });
|
|
128
|
+
|
|
129
|
+
let caught: ProcessExitError | null = null;
|
|
130
|
+
try {
|
|
131
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof ProcessExitError) caught = err;
|
|
134
|
+
else throw err;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
expect(caught).not.toBeNull();
|
|
138
|
+
expect(caught?.code).toBe(1);
|
|
139
|
+
expect(stderr.join("\n")).toContain("no skills found in any installed package");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("respects a custom target dir (--dest)", () => {
|
|
143
|
+
writeSkill(tmp, "@scope/a", "foo");
|
|
144
|
+
const customTarget = join(tmp, "custom-dest");
|
|
145
|
+
|
|
146
|
+
_runSkillsInstall({ cwd: tmp, target: customTarget, packageRoot: null });
|
|
147
|
+
|
|
148
|
+
expect(existsSync(join(customTarget, "foo", "SKILL.md"))).toBe(true);
|
|
149
|
+
expect(existsSync(join(targetDir(), "foo", "SKILL.md"))).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("source-repo mode discovers <packageRoot>/skills/", () => {
|
|
153
|
+
const fakePackageRoot = join(tmp, "fake-package");
|
|
154
|
+
mkdirSync(join(fakePackageRoot, "skills", "src-only"), { recursive: true });
|
|
155
|
+
writeFileSync(join(fakePackageRoot, "skills", "src-only", "SKILL.md"), "src-bundled");
|
|
156
|
+
mkdirSync(join(tmp, "node_modules"), { recursive: true });
|
|
157
|
+
|
|
158
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: fakePackageRoot });
|
|
159
|
+
|
|
160
|
+
expect(existsSync(join(targetDir(), "src-only", "SKILL.md"))).toBe(true);
|
|
161
|
+
expect(findLine("installed:")).toContain("src-only");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("summary always emits 'installed:' and omits 'kept' when zero kept", () => {
|
|
165
|
+
writeSkill(tmp, "@scope/a", "foo");
|
|
166
|
+
|
|
167
|
+
_runSkillsInstall({ cwd: tmp, target: targetDir(), packageRoot: null });
|
|
168
|
+
|
|
169
|
+
expect(stdout.some((l) => l.startsWith("installed:"))).toBe(true);
|
|
170
|
+
expect(stdout.some((l) => l.startsWith("kept"))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("_resolveTarget", () => {
|
|
175
|
+
test("returns <cwd>/.claude/skills by default", () => {
|
|
176
|
+
expect(_resolveTarget([], "/repo")).toBe("/repo/.claude/skills");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("honors --dest <dir>", () => {
|
|
180
|
+
expect(_resolveTarget(["--dest", "/elsewhere"], "/repo")).toBe("/elsewhere");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("honors -d <dir>", () => {
|
|
184
|
+
expect(_resolveTarget(["-d", "/elsewhere"], "/repo")).toBe("/elsewhere");
|
|
185
|
+
});
|
|
186
|
+
});
|