From 10087a48f589e0db17509cd6b8934852c32830f2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:52:22 -0400 Subject: [PATCH] feat(#790): emit Augment slash commands (~/.augment/commands/) (#808) Co-authored-by: Claude Sonnet 4.6 --- .changeset/790-augment-commands.md | 7 + bin/install.js | 69 ++++++++- docs/how-to/install-on-your-runtime.md | 2 +- src/runtime-artifact-layout.cts | 5 +- tests/enh-790-augment-commands.test.cjs | 177 ++++++++++++++++++++++++ tests/runtime-artifact-layout.test.cjs | 14 +- 6 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 .changeset/790-augment-commands.md create mode 100644 tests/enh-790-augment-commands.test.cjs diff --git a/.changeset/790-augment-commands.md b/.changeset/790-augment-commands.md new file mode 100644 index 000000000..94e609ac0 --- /dev/null +++ b/.changeset/790-augment-commands.md @@ -0,0 +1,7 @@ +--- +type: Added +pr: 801 +--- +**Augment (Auggie) installs now emit slash command definitions alongside skills.** A global `--augment` install writes `commands/gsd-.md` files to `~/.augment/commands/` in addition to the existing `skills/gsd-/SKILL.md` files, matching the integration depth of other fully-elevated runtimes and allowing Auggie users to invoke GSD as slash commands (`/gsd-phase`, `/gsd-ship`, etc.) without manual configuration (#790). Content rewrites (path normalisation and Augment-specific branding) are applied at install time. Uninstall removes the `gsd-*` command files while preserving user-owned commands. `mcpServers` registration is explicitly excluded — gsd ships no MCP server and does not register third-party servers. + + diff --git a/bin/install.js b/bin/install.js index 0b7e1f878..04257db81 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6320,6 +6320,46 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) { walkAndRewrite(stagedDir); } +/** + * Apply per-runtime content rewrites to flat .md files in a staged commands dir. + * Used for runtimes that have a commandsKind in their layout and need content rewrites + * (e.g. augment — replaces ~/.claude/ paths and applies branding conversions). + * + * IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged + * on a full/default profile (skills === '*'). This function MUST NOT mutate that source + * directory. It always copies to a temp dir first, rewrites there, and returns the new + * path so the caller installs from the temp copy, not the source. + * + * @param {string} stagedDir directory of staged flat .md command files (may be source dir) + * @param {string} runtime + * @param {string} pathPrefix + * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup) + */ +function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) { + if (!fs.existsSync(stagedDir)) return stagedDir; + // Always copy to a temp dir — stageSkillsForProfile() returns the original source + // dir on full/default profile (skills === '*'), so writing in-place would corrupt the + // package source. A temp copy is unconditional to keep the code simple and safe. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-')); + try { + for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8'); + content = _applyRuntimeRewrites(content, runtime, pathPrefix); + // For augment commands, apply the markdown conversion so tool references + // and skill paths use Augment equivalents. + if (runtime === 'augment') { + content = convertClaudeToAugmentMarkdown(content); + } + fs.writeFileSync(path.join(tempDir, entry.name), content); + } + } catch (err) { + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } + throw err; + } + return tempDir; +} + /** * Apply the per-runtime rewrite table to a single content string. * Extracted so it can be unit-tested independently of the filesystem walk. @@ -6718,8 +6758,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { for (const kind of layout.kinds) { const staged = kind.stage(resolvedProfile); + // stagedForCopy: the directory to copy from (may differ from staged if rewrites + // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace). + let stagedForCopy = staged; if (kind.kind === 'skills') { applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix); + } else if (kind.kind === 'commands') { + // Returns a temp dir with rewritten content so source files are never mutated. + stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix); } const dest = path.join(layout.configDir, kind.destSubpath); fs.mkdirSync(dest, { recursive: true }); @@ -6741,8 +6787,8 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { if (kind.prefix === '') { // Hermes: wipes entire dest dir — preserve anything not in staged. - const stagedNames = fs.existsSync(staged) - ? new Set(fs.readdirSync(staged, { withFileTypes: true }) + const stagedNames = fs.existsSync(stagedForCopy) + ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true }) .filter(e => e.isDirectory()).map(e => e.name)) : new Set(); for (const entry of fs.readdirSync(dest, { withFileTypes: true })) { @@ -6763,7 +6809,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { } _removeGsdEntries(dest, kind); - _copyStaged(staged, dest, kind); + _copyStaged(stagedForCopy, dest, kind); // Restore user-owned dirs after the prune+copy for (const [dirName, snap] of toPreserve) { @@ -6773,7 +6819,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { // For non-skills kinds (commands, agents): no user content to preserve; // just prune stale gsd-* entries and copy new ones. _removeGsdEntries(dest, kind); - _copyStaged(staged, dest, kind); + _copyStaged(stagedForCopy, dest, kind); } } } @@ -8955,6 +9001,21 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('skills/gsd-*'); } + // Augment: also verify commands/ (emitted alongside skills/) + if (isAugment) { + const commandsDir = path.join(targetDir, 'commands'); + if (fs.existsSync(commandsDir)) { + const cmdCount = fs.readdirSync(commandsDir) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/`); + } else { + failures.push('commands/gsd-*'); + } + } else { + failures.push('commands/gsd-*'); + } + } // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands) if (isCursor) { diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 9ae83a56e..85ec443b4 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -289,7 +289,7 @@ Qwen Code supports 15 hook events. GSD registers the following events automatica npx @opengsd/gsd-core@latest --augment --global ``` -Skills land in `~/.augment/`. GSD installs skills and agents. No hook or statusline ownership. +Skills land in `~/.augment/skills/` and slash command definitions land in `~/.augment/commands/`. GSD installs skills, agents, and commands (`/gsd-phase`, `/gsd-ship`, etc.). No hook or statusline ownership. --- diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index f0fe2ae59..24a504bf6 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -323,7 +323,10 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'augment': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir)]; + kinds = [ + commandsKind('commands', 'gsd-', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir), + ]; break; case 'trae': diff --git a/tests/enh-790-augment-commands.test.cjs b/tests/enh-790-augment-commands.test.cjs new file mode 100644 index 000000000..ba2e53d74 --- /dev/null +++ b/tests/enh-790-augment-commands.test.cjs @@ -0,0 +1,177 @@ +'use strict'; +/** + * Regression guard — enh(#790): Augment commands/ emitted alongside skills/. + * + * Verifies that a global Augment install writes: + * - commands/gsd-.md (slash command definitions) + * - skills/gsd-/SKILL.md (existing skill definitions) + * + * mcpServers in settings.json is explicitly excluded: gsd ships no MCP server + * and registering third-party servers is out of scope for the installer. + * + * Ref: https://docs.augmentcode.com/cli/reference — ~/.augment/commands/.md + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { installRuntimeArtifacts } = require('../bin/install.js'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-790 — augment layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout augment returns 2 kinds', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + assert.strictEqual(layout.kinds.length, 2, 'augment must have exactly 2 artifact kinds'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['commands', 'skills']); + }); + + test('augment commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + }); + + test('augment skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-790 — installRuntimeArtifacts augment emits both commands and skills', () => { + test('global augment install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh790-augment-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + // Commands dir + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + // Skills dir (pre-existing behavior preserved) + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('commands/gsd-help.md has Augment-compatible content (no raw ~/.claude/ refs)', (t) => { + const configDir = createTempDir('gsd-enh790-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + assert.ok(fs.existsSync(helpCmd), 'gsd-help.md must exist'); + const content = fs.readFileSync(helpCmd, 'utf8'); + // Should not have raw ~/.claude/ references after path rewrite + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh790-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + const skillsDir = path.join(configDir, 'skills'); + const cmdCount = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + // Regression guard: stageSkillsForProfile returns the real source dir on full profile + // (skills === '*'). applyRuntimeContentRewritesForCommandsInPlace must copy to temp + // before rewriting — it must NEVER write back to the source tree. + const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh790-full-'); + t.after(() => cleanup(configDir)); + + // Record source file content before install + const srcHelpPath = path.join(__dirname, '..', 'commands', 'gsd', 'help.md'); + const srcContentBefore = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); + + // Source file must be identical after install + const srcContentAfter = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(srcContentBefore, srcContentAfter, + 'source commands/gsd/help.md must not be mutated by the install'); + + // Installed command file must have rewrites applied (Augment path substitution) + const installedHelp = path.join(configDir, 'commands', 'gsd-help.md'); + assert.ok(fs.existsSync(installedHelp), 'installed gsd-help.md must exist'); + const installedContent = fs.readFileSync(installedHelp, 'utf8'); + assert.ok(!installedContent.includes('~/.claude/'), 'installed command must not have raw ~/.claude/ refs'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh790-uninstall-'); + t.after(() => cleanup(configDir)); + + const { uninstallRuntimeArtifacts } = require('../bin/install.js'); + + // Pre-create: a GSD command + a user-owned command + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('augment', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcpServers exclusion ──────────────────────────────────────────────────── + +describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => { + test('augment install does not write settings.json mcpServers', (t) => { + const configDir = createTempDir('gsd-enh790-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + // No settings.json with mcpServers should be written by the layout + const settingsPath = path.join(configDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(!settings.mcpServers, 'settings.json must not contain mcpServers (gsd ships no MCP server)'); + } + // If no settings.json at all, that is also correct + }); +}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index ecd975566..c2be44d97 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -145,15 +145,21 @@ describe('resolveRuntimeArtifactLayout — windsurf', () => { }); describe('resolveRuntimeArtifactLayout — augment', () => { - test('returns correct layout for augment', () => { + test('returns correct layout for augment (commands + skills)', () => { const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR); assert.strictEqual(layout.runtime, 'augment'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds.length, 2); + // commands kind first + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + // skills kind second + assert.strictEqual(layout.kinds[1].kind, 'skills'); + assert.strictEqual(layout.kinds[1].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); });