|
| 1 | +// `skill install` took the first unconsumed token as the source, so a flag it |
| 2 | +// does not know became the source itself and the real URL was dropped: for |
| 3 | +// `skill install -s user https://github.com/o/r.git` the source is `-s`, and |
| 4 | +// `user` and the URL are never looked at again. The source is spliced verbatim |
| 5 | +// into each engine's native argv, so gemini got `skills install -s --scope user` |
| 6 | +// and Claude got `git clone --depth 1 -s <skills-dir>/s` — where `-s` is git's |
| 7 | +// own `--shared`, which makes git read the *destination* as the repository |
| 8 | +// ("fatal: repository '…/skills/s' does not exist"). `mcp` already rejects a |
| 9 | +// stray flag for exactly this reason (test/mcp-stray-flag.test.mjs); this is |
| 10 | +// the same guard on the same kind of splice. |
| 11 | +import assert from "node:assert/strict"; |
| 12 | +import test from "node:test"; |
| 13 | + |
| 14 | +import { ENGINES } from "../src/engines.mjs"; |
| 15 | +import { skillCommand } from "../src/integrations.mjs"; |
| 16 | + |
| 17 | +const ALL = new Set(Object.keys(ENGINES)); |
| 18 | +const URL = "https://github.com/example/real-skill.git"; |
| 19 | + |
| 20 | +/** Run `fn` with console.log captured; returns { code, out }. */ |
| 21 | +async function capture(fn) { |
| 22 | + const log = console.log; |
| 23 | + const out = []; |
| 24 | + console.log = (...args) => out.push(args.join(" ")); |
| 25 | + try { return { code: await fn(), out: out.join("\n") }; } |
| 26 | + finally { console.log = log; } |
| 27 | +} |
| 28 | + |
| 29 | +/** A `run` that records every argv it is handed and always succeeds. */ |
| 30 | +function spy() { |
| 31 | + const calls = []; |
| 32 | + const run = async (cmd, args) => { calls.push([cmd, ...args].join(" ")); return { ok: true, code: 0 }; }; |
| 33 | + return { calls, run }; |
| 34 | +} |
| 35 | + |
| 36 | +// --- the bug ----------------------------------------------------------------- |
| 37 | + |
| 38 | +test("an engine-native scope flag is rejected instead of becoming the source", async () => { |
| 39 | + const { run, calls } = spy(); |
| 40 | + const { code, out } = await capture(() => skillCommand(["install", "-s", "user", URL], { run, installedSet: ALL })); |
| 41 | + assert.equal(code, 1, "a stray flag was accepted as the skill source"); |
| 42 | + assert.match(out, /unknown skill flag "-s"/); |
| 43 | + assert.deepEqual(calls, [], "no engine should be run at all"); |
| 44 | +}); |
| 45 | + |
| 46 | +test("a misspelled --name is rejected, not installed as a skill called nmae", async () => { |
| 47 | + const { run, calls } = spy(); |
| 48 | + const { code, out } = await capture(() => skillCommand(["install", "--nmae", "my-skill", URL], { run, installedSet: ALL })); |
| 49 | + assert.equal(code, 1); |
| 50 | + assert.match(out, /unknown skill flag "--nmae"/); |
| 51 | + assert.deepEqual(calls, [], "no engine should be run at all"); |
| 52 | +}); |
| 53 | + |
| 54 | +test("the stray flag never reaches git's or gemini's argv", async () => { |
| 55 | + // The end-to-end consequence: `-s` is git's --shared, so the clone would have |
| 56 | + // read its own destination as the repository. |
| 57 | + const { run, calls } = spy(); |
| 58 | + await capture(() => skillCommand(["install", "-s", "user", URL], { run, installedSet: ALL })); |
| 59 | + assert.ok(!calls.some((c) => c.includes(" -s")), `a stray flag was spliced into ${calls.join(" | ")}`); |
| 60 | +}); |
| 61 | + |
| 62 | +test("the user's real source is never silently dropped in favour of a flag", async () => { |
| 63 | + const { run, calls } = spy(); |
| 64 | + const { code } = await capture(() => skillCommand(["install", "--scope", "user", URL], { run, installedSet: ALL })); |
| 65 | + assert.equal(code, 1); |
| 66 | + assert.ok(!calls.some((c) => c.includes(URL)), "nothing should have been installed"); |
| 67 | +}); |
| 68 | + |
| 69 | +test("the error names the flag skill install does take, and how to escape a real one", async () => { |
| 70 | + const { run } = spy(); |
| 71 | + const { out } = await capture(() => skillCommand(["install", "-s", "user", URL], { run, installedSet: ALL })); |
| 72 | + assert.match(out, /--name/); |
| 73 | + assert.match(out, /\.\/-s/); |
| 74 | +}); |
| 75 | + |
| 76 | +// --- controls: the opposite direction --------------------------------------- |
| 77 | + |
| 78 | +test("a normal git URL still installs across the skills engines", async () => { |
| 79 | + const { run, calls } = spy(); |
| 80 | + const { code } = await capture(() => skillCommand(["install", URL], { run, installedSet: ALL })); |
| 81 | + assert.equal(code, 0); |
| 82 | + assert.ok(calls.some((c) => c.startsWith("git clone") && c.includes(URL)), `expected a clone of the source, got ${calls.join(" | ")}`); |
| 83 | + assert.ok(calls.some((c) => c.startsWith(`${ENGINES.gemini.bin} skills install ${URL}`)), `expected gemini to be handed the source, got ${calls.join(" | ")}`); |
| 84 | +}); |
| 85 | + |
| 86 | +test("--name still parses and still names the skill", async () => { |
| 87 | + const { run, calls } = spy(); |
| 88 | + const { code } = await capture(() => skillCommand(["install", URL, "--name", "renamed"], { run, installedSet: ALL })); |
| 89 | + assert.equal(code, 0); |
| 90 | + assert.ok(calls.some((c) => c.includes("/renamed")), `expected the clone to land in .../renamed, got ${calls.join(" | ")}`); |
| 91 | +}); |
| 92 | + |
| 93 | +test("a local path source is untouched by the guard", async () => { |
| 94 | + const { run, calls } = spy(); |
| 95 | + const { code } = await capture(() => skillCommand(["install", "./my-skill"], { run, installedSet: ALL })); |
| 96 | + assert.equal(code, 0); |
| 97 | + assert.ok(calls.some((c) => c.includes("./my-skill")), `expected the path to survive, got ${calls.join(" | ")}`); |
| 98 | +}); |
| 99 | + |
| 100 | +test("the pre-existing --name missing-value guard still fires first", async () => { |
| 101 | + const { run } = spy(); |
| 102 | + const { code, out } = await capture(() => skillCommand(["install", URL, "--name"], { run, installedSet: ALL })); |
| 103 | + assert.equal(code, 1); |
| 104 | + assert.match(out, /--name requires a value/); |
| 105 | + assert.doesNotMatch(out, /unknown skill flag/); |
| 106 | +}); |
| 107 | + |
| 108 | +test("no source at all still reports usage, not an unknown flag", async () => { |
| 109 | + const { run } = spy(); |
| 110 | + const { code, out } = await capture(() => skillCommand(["install"], { run, installedSet: ALL })); |
| 111 | + assert.equal(code, 1); |
| 112 | + assert.match(out, /usage: \/skill install/); |
| 113 | + assert.doesNotMatch(out, /unknown skill flag/); |
| 114 | +}); |
| 115 | + |
| 116 | +test("an unknown verb still reports an unknown verb", async () => { |
| 117 | + const { run } = spy(); |
| 118 | + const { code, out } = await capture(() => skillCommand(["bogus"], { run, installedSet: ALL })); |
| 119 | + assert.equal(code, 1); |
| 120 | + assert.match(out, /unknown skill verb/); |
| 121 | +}); |
0 commit comments