* 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>
This commit is contained in:
5
.changeset/778-cross-runtime-command-enrichment.md
Normal file
5
.changeset/778-cross-runtime-command-enrichment.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 825
|
||||
---
|
||||
Cross-runtime command enrichment in the installer. Gemini CLI commands now use native `{{args}}` interpolation (translated from Claude's `$ARGUMENTS`) so typed arguments interpolate into the prompt body, and `/gsd:progress` injects live project state via a fixed, injection-safe `!{cat .planning/STATE.md 2>/dev/null}` shell block. Qwen Code skills now carry a numeric `priority` field so the most-used main-loop workflows (`new-project`, `plan-phase`, `execute-phase`, …) surface first in the `/skills` list. The OpenCode per-command `model`/`agent`/`subtask` enrichment was evaluated and intentionally not implemented — `model` would reintroduce the ProviderModelNotFoundError regression that the converter deliberately guards against for non-Anthropic providers (#1156), `subtask`/`agent` change execution semantics for GSD's interactive commands, and `variant` is not in the OpenCode command schema. (#778)
|
||||
@@ -2108,6 +2108,40 @@ function skillFrontmatterName(skillDirName) {
|
||||
return skillDirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Code skills accept an optional numeric `priority` frontmatter field.
|
||||
* Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
|
||||
* #778): HIGHER values sort EARLIER in the `/skills` TUI listing (omitted ≈ 0;
|
||||
* negatives sort below unset). It affects ONLY the `/skills` list order —
|
||||
* slash-command completion and the `/help` view stay alphabetical.
|
||||
*
|
||||
* We assign descending priorities to GSD's main-loop commands so the most-used
|
||||
* workflow skills surface first; utility skills are deliberately left unset
|
||||
* (default 0) and sort below.
|
||||
*
|
||||
* NOTE: the #778 issue body proposed the INVERSE numbering (plan-phase: 10,
|
||||
* utilities: 90+). The verified spec shows that would BURY the core loop below
|
||||
* utilities, so we implement the spec-correct direction (core = high) instead.
|
||||
* Keyed by command stem (skill dir is `gsd-<stem>`).
|
||||
*/
|
||||
const QWEN_SKILL_PRIORITY = Object.freeze({
|
||||
'new-project': 100,
|
||||
'discuss-phase': 95,
|
||||
'plan-phase': 90,
|
||||
'execute-phase': 85,
|
||||
progress: 80,
|
||||
'verify-work': 75,
|
||||
phase: 70,
|
||||
review: 65,
|
||||
ship: 60,
|
||||
config: 55,
|
||||
surface: 50,
|
||||
'resume-work': 45,
|
||||
'pause-work': 40,
|
||||
help: 35,
|
||||
update: 30,
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
|
||||
* Claude Code is the native format, so minimal conversion needed —
|
||||
@@ -2151,6 +2185,18 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
||||
// Track GSD's package version so Hermes' skill_view() reports a stable
|
||||
// identifier per install.
|
||||
if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
|
||||
// #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
|
||||
// so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
|
||||
// we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
|
||||
if (runtime === 'qwen') {
|
||||
const stem = typeof skillName === 'string' && skillName.startsWith('gsd-')
|
||||
? skillName.slice(4)
|
||||
: skillName;
|
||||
const priority = Object.prototype.hasOwnProperty.call(QWEN_SKILL_PRIORITY, stem)
|
||||
? QWEN_SKILL_PRIORITY[stem]
|
||||
: undefined;
|
||||
if (typeof priority === 'number') fm += `priority: ${priority}\n`;
|
||||
}
|
||||
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
|
||||
if (agent) fm += `agent: ${agent}\n`;
|
||||
// #769: emit context: and effort: when present so the runtime can honour
|
||||
@@ -5875,7 +5921,7 @@ function convertSlashCommandsToGeminiMentions(content) {
|
||||
});
|
||||
}
|
||||
|
||||
function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
||||
function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) {
|
||||
// Apply Gemini-specific slash command namespacing
|
||||
let converted = convertSlashCommandsToGeminiMentions(content);
|
||||
// Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
|
||||
@@ -5887,8 +5933,9 @@ function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
||||
converted = stripSubTags(converted);
|
||||
|
||||
if (isCommand) {
|
||||
// Convert to Gemini TOML format
|
||||
converted = convertClaudeToGeminiToml(converted);
|
||||
// Convert to Gemini TOML format (threads the command name so per-command
|
||||
// enrichment — e.g. the #778 live-state injection — can target a command).
|
||||
converted = convertClaudeToGeminiToml(converted, { commandName });
|
||||
}
|
||||
|
||||
return converted;
|
||||
@@ -6392,7 +6439,16 @@ function convertClaudeCommandToKiloSkill(content, skillName) {
|
||||
* @param {string} content - Markdown file content with YAML frontmatter
|
||||
* @returns {string} - TOML content
|
||||
*/
|
||||
function convertClaudeToGeminiToml(content) {
|
||||
function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
|
||||
// #778 (c) — Gemini {{args}} interpolation. Claude's $ARGUMENTS placeholder
|
||||
// maps to Gemini's {{args}} so inline argument references interpolate into the
|
||||
// command body instead of being emitted as a dead literal. Applied before
|
||||
// frontmatter parsing so every return path benefits (a command's frontmatter
|
||||
// never contains $ARGUMENTS, so this is body-only in practice). Gemini injects
|
||||
// {{args}} as typed outside shell blocks; we never place it inside a !{...}
|
||||
// block, so there is no shell-escaping/injection interaction.
|
||||
content = content.replace(/\$ARGUMENTS\b/g, '{{args}}');
|
||||
|
||||
// Check if content has frontmatter
|
||||
if (!content.startsWith('---')) {
|
||||
return `prompt = ${JSON.stringify(content)}\n`;
|
||||
@@ -6404,7 +6460,27 @@ function convertClaudeToGeminiToml(content) {
|
||||
}
|
||||
|
||||
const frontmatter = content.substring(3, endIndex).trim();
|
||||
const body = content.substring(endIndex + 3).trim();
|
||||
let body = content.substring(endIndex + 3).trim();
|
||||
|
||||
// #778 (c) — Gemini !{...} dynamic-output injection for the situational
|
||||
// `progress` command (GSD's status/dashboard surface). Inject the live
|
||||
// .planning/STATE.md so the model sees current project state without relying
|
||||
// on session memory.
|
||||
//
|
||||
// SECURITY: the shell command is a FIXED `cat` with NO interpolated user
|
||||
// input — no {{args}} appears inside the block — so there is no
|
||||
// shell-injection vector. Gemini still shows its standard per-invocation
|
||||
// confirmation dialog (verified behavior). `2>/dev/null` keeps an
|
||||
// uninitialized project (missing STATE.md) from injecting stderr noise.
|
||||
// Braces inside the block are balanced (none present), per Gemini's parser
|
||||
// requirement. The append happens AFTER the {{args}} mapping above so the
|
||||
// injected block can never accidentally carry interpolated arguments.
|
||||
if (commandName === 'progress') {
|
||||
body += '\n\n## Live project state\n'
|
||||
+ 'Current contents of `.planning/STATE.md` '
|
||||
+ '(empty if the project is not yet initialized):\n\n'
|
||||
+ '!{cat .planning/STATE.md 2>/dev/null}\n';
|
||||
}
|
||||
|
||||
// Extract description from frontmatter
|
||||
let description = '';
|
||||
@@ -7431,8 +7507,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
||||
: convertClaudeToOpencodeFrontmatter(content);
|
||||
fs.writeFileSync(destPath, content);
|
||||
} else if (isGemini) {
|
||||
// Apply Gemini-specific Markdown transformations (slash commands, TOML)
|
||||
const processed = convertClaudeToGeminiMarkdown(content, { isCommand });
|
||||
// Apply Gemini-specific Markdown transformations (slash commands, TOML).
|
||||
// #778: thread the command name (file stem) so per-command TOML
|
||||
// enrichment (live-state injection) can target a specific command.
|
||||
const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null;
|
||||
const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName });
|
||||
const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
|
||||
fs.writeFileSync(finalPath, processed);
|
||||
} else if (isCodex) {
|
||||
|
||||
@@ -709,6 +709,15 @@ Both enrichments are written automatically at install time and require no manual
|
||||
|
||||
See [Runtime-Aware Profiles](CONFIGURATION.md#runtime-aware-profiles-2517).
|
||||
|
||||
#### Per-runtime command enrichment
|
||||
|
||||
When generating artifacts, the installer adapts GSD commands to each runtime's native command schema:
|
||||
|
||||
- **Gemini CLI** — generated TOML commands use Gemini's `{{args}}` placeholder (translated from Claude's `$ARGUMENTS`) so typed arguments interpolate into the prompt, and `/gsd:progress` injects live project state via a fixed `!{cat .planning/STATE.md 2>/dev/null}` shell block (no interpolated input, so no injection risk; Gemini shows its standard confirmation dialog).
|
||||
- **Qwen Code** — main-loop skills carry Qwen's numeric `priority` field so the most-used workflows (e.g. `new-project`, `plan-phase`, `execute-phase`) sort first in the `/skills` list; utility skills are left unset. Higher values sort earlier; the field affects only the `/skills` list order.
|
||||
|
||||
See [How to install GSD Core on your runtime](how-to/install-on-your-runtime.md) for the full per-runtime details.
|
||||
|
||||
### Manual install / no-Node.js setup
|
||||
|
||||
If you cannot run the GSD installer, you cannot use the source files in `agents/` directly — they are in Claude Code's native frontmatter format. For OpenCode, two transformations are required:
|
||||
|
||||
@@ -96,6 +96,11 @@ npx @opengsd/gsd-core@latest --gemini --global
|
||||
|
||||
Skills land in `~/.gemini/`. The installer rewrites all command bodies to Gemini's colon namespace (`/gsd:update`, `/gsd:config`, etc.). Restart Gemini CLI after install.
|
||||
|
||||
The installer also enriches the generated TOML commands with two native Gemini custom-command features:
|
||||
|
||||
- **`{{args}}` interpolation** — every command that references arguments inline is emitted with Gemini's `{{args}}` placeholder (translated from Claude's `$ARGUMENTS`), so flags and free-text you type after the command name are interpolated into the prompt body rather than ignored.
|
||||
- **`!{...}` live-state injection** — `/gsd:progress` injects the current contents of `.planning/STATE.md` via a fixed `!{cat .planning/STATE.md 2>/dev/null}` shell block, giving Gemini live project state without relying on session memory. The shell block contains no interpolated input, so there is no injection risk; Gemini still shows its standard confirmation dialog the first time the command runs in a session.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
```bash
|
||||
@@ -290,6 +295,8 @@ npx @opengsd/gsd-core@latest --qwen --global
|
||||
|
||||
Skills land in `~/.qwen/skills/gsd-*/SKILL.md`.
|
||||
|
||||
GSD's main-loop skills are emitted with Qwen's optional numeric `priority` frontmatter field so the most-used workflows surface first in the `/skills` TUI list. Higher values sort earlier (per Qwen's skills spec), so core commands such as `/skills` for `new-project` (100), `plan-phase` (90), and `execute-phase` (85) appear above utility skills, which are left unset (default 0). This affects only the `/skills` list order — slash-command completion and `/help` remain alphabetical.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
```bash
|
||||
|
||||
227
tests/enh-778-cross-runtime-command-enrichment.test.cjs
Normal file
227
tests/enh-778-cross-runtime-command-enrichment.test.cjs
Normal file
@@ -0,0 +1,227 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user