* feat(#778): cross-runtime command enrichment (Gemini {{args}}/!{}, Qwen priority) Enrich the installer's per-runtime command/skill generators with native, verified, additive fields: - Gemini CLI: map Claude's $ARGUMENTS -> Gemini's {{args}} in generated TOML commands so typed arguments interpolate; inject live .planning/STATE.md into /gsd:progress via a fixed, injection-safe !{cat .planning/STATE.md 2>/dev/null} shell block (no interpolated input). - Qwen Code: emit the optional numeric `priority` field on main-loop skills so the most-used workflows sort first in the /skills list (higher = earlier per the Qwen skills spec; the issue's inverse numbering was corrected). OpenCode per-command model/agent/subtask/variant enrichment was evaluated and intentionally not implemented: `model` reintroduces the #1156 ProviderModelNotFoundError regression for non-Anthropic providers (the converter deliberately strips model:), `subtask`/`agent` change execution semantics for GSD's interactive commands, and `variant` is not in the OpenCode command schema. Schemas verified against primary docs (Gemini custom-commands, Qwen skills, OpenCode commands/skills). Adds tests/enh-778-* and how-to + USER-GUIDE docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#778): set changeset PR number to 825 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
9.5 KiB
JavaScript
228 lines
9.5 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// Reads .md/SKILL.md/.toml product files whose deployed text IS what the
|
|
// runtime loads — testing text content tests the deployed contract.
|
|
|
|
/**
|
|
* GSD Tools Tests — #778 cross-runtime command enrichment.
|
|
*
|
|
* Two independently-verified, additive sub-features:
|
|
* (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the
|
|
* /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'.
|
|
* (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a
|
|
* fixed `!{cat .planning/STATE.md}` live-state injection on the
|
|
* situational `progress` command (injection-safe — no interpolated input).
|
|
*
|
|
* The OpenCode sub-feature (per-command model/agent/subtask/variant) is
|
|
* intentionally NOT implemented — see PR description: `model` reintroduces the
|
|
* #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users,
|
|
* `subtask`/`agent` change execution semantics for GSD's interactive commands,
|
|
* and `variant` is not in the OpenCode command schema.
|
|
*
|
|
* Uses node:test and node:assert (NOT Jest).
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const {
|
|
convertClaudeCommandToClaudeSkill,
|
|
convertClaudeToGeminiMarkdown,
|
|
install,
|
|
} = require('../bin/install.js');
|
|
|
|
// ─── (b) Qwen Code: priority ordering ───────────────────────────────────────
|
|
|
|
describe('#778 (b) Qwen skills priority', () => {
|
|
const mk = (name, desc, body) =>
|
|
['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n');
|
|
|
|
test('emits numeric priority for a core-loop command (runtime=qwen)', () => {
|
|
const result = convertClaudeCommandToClaudeSkill(
|
|
mk('plan-phase', 'Plan a phase', 'Body.'),
|
|
'gsd-plan-phase',
|
|
'qwen',
|
|
[]
|
|
);
|
|
const m = result.match(/^priority:\s*(\d+)\s*$/m);
|
|
assert.ok(m, 'priority field present for gsd-plan-phase');
|
|
assert.equal(Number(m[1]) > 0, true, 'priority is a positive number');
|
|
});
|
|
|
|
test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => {
|
|
const np = convertClaudeCommandToClaudeSkill(
|
|
mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', []
|
|
).match(/^priority:\s*(\d+)/m);
|
|
const help = convertClaudeCommandToClaudeSkill(
|
|
mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', []
|
|
).match(/^priority:\s*(\d+)/m);
|
|
assert.ok(np && help, 'both core and mid-tier get a priority');
|
|
assert.ok(
|
|
Number(np[1]) > Number(help[1]),
|
|
'new-project (core) sorts earlier than help (utility) — higher value'
|
|
);
|
|
});
|
|
|
|
test('utility command NOT in the priority map gets no priority field', () => {
|
|
const result = convertClaudeCommandToClaudeSkill(
|
|
mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', []
|
|
);
|
|
assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility');
|
|
});
|
|
|
|
test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => {
|
|
for (const rt of [null, 'claude', 'hermes']) {
|
|
const result = convertClaudeCommandToClaudeSkill(
|
|
mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, []
|
|
);
|
|
assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── (c) Gemini: {{args}} interpolation ─────────────────────────────────────
|
|
|
|
describe('#778 (c) Gemini {{args}} interpolation', () => {
|
|
const cmd = (body) =>
|
|
['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n');
|
|
|
|
test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => {
|
|
const out = convertClaudeToGeminiMarkdown(
|
|
cmd('Operate on $ARGUMENTS now.'),
|
|
{ isCommand: true, commandName: 'demo' }
|
|
);
|
|
assert.ok(out.includes('{{args}}'), '{{args}} present');
|
|
assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed');
|
|
assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape');
|
|
});
|
|
|
|
test('command without $ARGUMENTS gets no injected {{args}}', () => {
|
|
const out = convertClaudeToGeminiMarkdown(
|
|
cmd('No arguments referenced here.'),
|
|
{ isCommand: true, commandName: 'demo' }
|
|
);
|
|
assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}');
|
|
});
|
|
|
|
test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => {
|
|
const out = convertClaudeToGeminiMarkdown(
|
|
cmd('Reference $ARGUMENTS.'),
|
|
{ isCommand: false }
|
|
);
|
|
// isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping
|
|
// (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path).
|
|
assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt');
|
|
assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content');
|
|
assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path');
|
|
});
|
|
});
|
|
|
|
// ─── (c) Gemini: end-to-end install wiring ──────────────────────────────────
|
|
// Proves the install path derives the per-command name from the file stem so a
|
|
// regression in the call-site wiring (not just the converter) is caught.
|
|
|
|
describe('#778 (c) Gemini install wiring (end-to-end)', () => {
|
|
let tmpDir;
|
|
let tmpHome;
|
|
let prevCwd;
|
|
let prevHome;
|
|
let prevUserprofile;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-enh778-gem-');
|
|
tmpHome = createTempDir('gsd-enh778-home-');
|
|
prevCwd = process.cwd();
|
|
prevHome = process.env.HOME;
|
|
prevUserprofile = process.env.USERPROFILE;
|
|
process.chdir(tmpDir);
|
|
// Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037
|
|
// local-install conflict-skip path.
|
|
process.env.HOME = tmpHome;
|
|
process.env.USERPROFILE = tmpHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(prevCwd);
|
|
if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome;
|
|
if (prevUserprofile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = prevUserprofile;
|
|
cleanup(tmpDir);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => {
|
|
const oldLog = console.log;
|
|
console.log = () => {};
|
|
try {
|
|
install(false, 'gemini');
|
|
} finally {
|
|
console.log = oldLog;
|
|
}
|
|
|
|
const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd');
|
|
const progressToml = path.join(commandsDir, 'progress.toml');
|
|
assert.ok(fs.existsSync(progressToml), 'progress.toml installed');
|
|
const progress = fs.readFileSync(progressToml, 'utf8');
|
|
// Proves commandName was derived as 'progress' from the file stem.
|
|
assert.ok(
|
|
progress.includes('!{cat .planning/STATE.md 2>/dev/null}'),
|
|
'progress.toml has the live-state shell block'
|
|
);
|
|
|
|
// A non-situational command must NOT receive the shell block.
|
|
const helpToml = path.join(commandsDir, 'help.toml');
|
|
if (fs.existsSync(helpToml)) {
|
|
assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block');
|
|
}
|
|
|
|
// At least one installed command must use {{args}} and none may retain a
|
|
// literal $ARGUMENTS (every command body's $ARGUMENTS is translated).
|
|
const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml'));
|
|
const withArgs = tomls.filter((f) =>
|
|
fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}'));
|
|
const withLiteral = tomls.filter((f) =>
|
|
fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS'));
|
|
assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}');
|
|
assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS');
|
|
});
|
|
});
|
|
|
|
// ─── (c) Gemini: !{...} live-state injection (progress) ─────────────────────
|
|
|
|
describe('#778 (c) Gemini !{} live-state injection', () => {
|
|
const cmd = (name) =>
|
|
['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n');
|
|
|
|
test('progress command injects a fixed !{cat .planning/STATE.md} block', () => {
|
|
const out = convertClaudeToGeminiMarkdown(
|
|
cmd('progress'),
|
|
{ isCommand: true, commandName: 'progress' }
|
|
);
|
|
assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present');
|
|
});
|
|
|
|
test('non-progress commands get no !{} shell block', () => {
|
|
const out = convertClaudeToGeminiMarkdown(
|
|
cmd('help'),
|
|
{ isCommand: true, commandName: 'help' }
|
|
);
|
|
assert.ok(!out.includes('!{'), 'no shell block for non-situational command');
|
|
});
|
|
|
|
test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => {
|
|
const out = convertClaudeToGeminiMarkdown(
|
|
['---', 'name: gsd:progress', 'description: progress', '---', '',
|
|
'Body uses $ARGUMENTS too.'].join('\n'),
|
|
{ isCommand: true, commandName: 'progress' }
|
|
);
|
|
const blocks = out.match(/!\{([^}]*)\}/g) || [];
|
|
assert.equal(blocks.length, 1, 'exactly one shell block');
|
|
assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block');
|
|
assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only');
|
|
});
|
|
});
|