Skip to content

Commit cfbb836

Browse files
fix(skills): reject an unknown flag instead of installing it as the skill source (#227)
`skill install` took the first unconsumed token as the source, so a flag it does not know became the source itself and the URL the user typed was dropped: `skill install -s user https://github.com/o/r.git` installs a skill called `s` from a source of `-s`, and never looks at `user` or the URL again. The source is spliced verbatim into each engine's native argv, so gemini gets `skills install -s --scope user` and Claude gets `git clone --depth 1 -s <skills-dir>/s`, where `-s` is git's own `--shared` and makes git read the destination as the repository. `mcp` already rejects a stray flag for this exact reason; this applies the same guard to the same kind of splice. Co-authored-by: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com>
1 parent 899c576 commit cfbb836

2 files changed

Lines changed: 132 additions & 0 deletions

File tree

src/integrations.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,17 @@ export async function skillCommand(tokens, { run, installedSet } = {}) {
240240
}
241241
else if (!source) source = rest[i];
242242
}
243+
// A source still starting with `-` was never consumed as a flag, so it is a
244+
// typo or an engine-native flag moshcode does not take (`-s user`). Left
245+
// alone it becomes the skill SOURCE and is spliced straight into every
246+
// engine's own argv — `gemini skills install -s --scope user`, and a
247+
// `git clone --depth 1 -s <dest>` where `-s` (`--shared`) makes git read the
248+
// destination as the repository — while the URL the user actually typed is
249+
// dropped on the floor. Same guard `mcp` already applies to its own spec.
250+
if (source?.startsWith("-")) {
251+
console.log(err(`unknown skill flag "${source}" — skill install takes --name; a source that really starts with "-" must be written as ./${source}`));
252+
return 1;
253+
}
243254
if (!source) { console.log(err("usage: /skill install <git-url|path> [--name <name>]")); return 1; }
244255

245256
const spec = { source, name: skillName(source, name) };

test/skill-stray-flag.test.mjs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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

Comments
 (0)