diff --git a/.changeset/2097-eos-augment-imperative-adapter.md b/.changeset/2097-eos-augment-imperative-adapter.md new file mode 100644 index 000000000..fe67b40f2 --- /dev/null +++ b/.changeset/2097-eos-augment-imperative-adapter.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 2166 +--- + +**Augment Code now installs through its capability descriptor, with a native MCP companion** — installing GSD into Augment registers the GSD companion server in Augment's `settings.json` `mcpServers` and drives command/skill/agent conversion from Augment's negotiated descriptor instead of hardcoded runtime special-cases. (#2097) diff --git a/bin/install.js b/bin/install.js index c5bfdb513..027c6c8c6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2606,14 +2606,6 @@ function convertClaudeAgentToWindsurfAgent(content) { // Augment uses a tool set similar to Cursor/Windsurf. // Config lives in .augment/ (local) and ~/.augment/ (global). -const claudeToAugmentTools = { - Bash: 'launch-process', - Edit: 'str-replace-editor', - AskUserQuestion: null, - SlashCommand: null, - TodoWrite: 'add_tasks', -}; - // #1675 (ADR-1508): the augment converter family below was a byte-identical // duplicate of runtime-artifact-conversion.cjs: // convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown, @@ -7546,6 +7538,21 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { } } + // #2097 UPGRADE 3 — Remove the MCP companion entry from settings.json for + // runtimes that host MCP there (Augment), symmetric to the mcp_config.json + // removal for Antigravity below. Only the GSD-owned mcpServers.gsd key is + // removed — any other user-configured MCP servers are preserved. + if (_hostBehaviors(runtime).mcpCompanion === 'settings-json' && + settings.mcpServers && typeof settings.mcpServers === 'object' && + settings.mcpServers.gsd !== undefined) { + delete settings.mcpServers.gsd; + if (Object.keys(settings.mcpServers).length === 0) { + delete settings.mcpServers; + } + settingsModified = true; + console.log(` ${green}✓${reset} Removed GSD MCP companion server from settings.json`); + } + if (settingsModified) { writeSettings(settingsPath, settings); removedCount++; @@ -8078,6 +8085,27 @@ function configureAntigravityMcpConfig(isGlobal = true, configDir = null) { console.log(` ${green}✓${reset} Configured Antigravity MCP companion server (gsd)`); } +/** + * #2097 (ADR-1239 transport:mcp): register the GSD companion MCP server inside a + * runtime's settings.json (Augment hosts MCP in settings.json.mcpServers, unlike + * Antigravity's standalone mcp_config.json). Mutates the in-memory settings object + * that finishInstall already writes — non-destructive + idempotent: only sets + * mcpServers.gsd, preserving any user-defined servers (a user's own `gsd` override + * is respected — Hyrum's Law). + * @param {object} settings - the in-memory settings object finishInstall will write + */ +function mergeGsdMcpServerIntoSettings(settings) { + if (!settings.mcpServers || typeof settings.mcpServers !== 'object' || Array.isArray(settings.mcpServers)) { + settings.mcpServers = {}; + } + if (settings.mcpServers.gsd === undefined) { + settings.mcpServers.gsd = { + command: 'npx', + args: ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server'], + }; + } +} + /** * Verify a directory exists and contains files */ @@ -9643,10 +9671,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = convertClaudeAgentToCopilotAgent(content, isGlobal); } else if (isWindsurf) { content = convertClaudeAgentToWindsurfAgent(content); - } else if (isAugment) { - content = convertClaudeAgentToAugmentAgent(content); - } else if (isTrae) { - content = convertClaudeAgentToTraeAgent(content); } else if (isCodebuddy) { content = convertClaudeAgentToCodebuddyAgent(content); } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') { @@ -10918,6 +10942,12 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS mergeClaudePermissions(settings); } + // #2097 UPGRADE 3 (transport:mcp): companion MCP server for runtimes that host + // MCP in settings.json (Augment). settings.json is golden-excluded, so no golden change. + if (_hostBehaviors(runtime).mcpCompanion === 'settings-json' && settings && plan.writesSharedSettings) { + mergeGsdMcpServerIntoSettings(settings); + } + // Write settings when runtime supports settings.json. // #3002 CR: defense-in-depth — re-run validateHookFields right before // serialization. The push-site guards above already skip null-command @@ -11930,6 +11960,8 @@ module.exports = { buildAntigravityAllowRules, configureAntigravityPermissions, configureAntigravityMcpConfig, + // #2097 UPGRADE 3 — Augment MCP companion (settings.json-hosted) + mergeGsdMcpServerIntoSettings, claudeToCopilotTools, convertCopilotToolName, convertClaudeToCopilotContent, diff --git a/capabilities/augment/capability.json b/capabilities/augment/capability.json index a7e7c5683..a712d16f2 100644 --- a/capabilities/augment/capability.json +++ b/capabilities/augment/capability.json @@ -82,6 +82,10 @@ "writesSharedSettings": true, "permissionWriter": null, "extendedHookEvents": [], + "hostBehaviors": { + "commandBodyConverter": "convertClaudeToAugmentMarkdown", + "mcpCompanion": "settings-json" + }, "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", diff --git a/docs/how-to/connect-gsd-mcp-server.md b/docs/how-to/connect-gsd-mcp-server.md index 37cbbed89..468289f8a 100644 --- a/docs/how-to/connect-gsd-mcp-server.md +++ b/docs/how-to/connect-gsd-mcp-server.md @@ -1,7 +1,8 @@ # How to connect a host to the GSD companion MCP server This guide shows you how to make a MCP-capable host (Claude Code, Codex, -OpenCode, VS Code, Antigravity CLI, Cursor, Cline, Hermes) drive GSD — run GSD +OpenCode, VS Code, Antigravity CLI, Cursor, Cline, Hermes, Augment Code) drive +GSD — run GSD commands and read/write `.planning/` state — through the companion MCP server, with no bespoke plugin. @@ -28,6 +29,11 @@ host. - **Claude Code / Codex / Cursor / Cline / Hermes** — under the host's `mcpServers` object (project or user config). +- **Augment Code** — under the `mcpServers` block of its own + `settings.json` (not a standalone MCP config file, unlike Antigravity) + — global at `~/.augment/settings.json`, project-local at + `.augment/settings.json`. GSD's installer configures this entry + automatically (`--augment` installs). - **VS Code** — in the workspace MCP servers list. - **Antigravity** — under the `mcpServers` block of its standalone `mcp_config.json` profile (not embedded in `settings.json`) — global at diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 6835d0e00..70fc779f6 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -397,7 +397,7 @@ Qwen Code supports 15 hook events. GSD registers the following events automatica npx @opengsd/gsd-core@latest --augment --global ``` -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. +Skills land in `~/.augment/skills/` and slash command definitions land in `~/.augment/commands/`. GSD installs skills, agents, and commands (`/gsd-phase`, `/gsd-ship`, etc.). GSD's managed lifecycle hooks are registered into Augment's own `settings.json` `hooks` block (Claude hook event dialect, covering session-start, tool-use, and phase-boundary events) — no statusline ownership. #2097 also registers the GSD companion MCP server under `settings.json`'s `mcpServers.gsd` (see [Connect a host to the GSD MCP server](connect-gsd-mcp-server.md)). --- diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index c8046ec56..1898c9215 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -334,6 +334,35 @@ Documentation gaps: - dispatch.nested - dispatch.maxDepth +**EoS migration status (#2097):** Folded onto descriptor-driven dispatch. +Augment already installed through the declarative adapter (nested-skill +artifact layout, `settings-json` hook surface, Claude hook event dialect), but +carried two remaining runtime-literal branches in +`src/runtime-artifact-conversion.cts`: the 4 `~/.augment`/`$HOME/.augment` +dot-dir rewrites in `_applyRuntimeRewrites`'s `case 'augment':` block are now +built from `getDirName('augment')` (dirName-derived, byte-identical) instead +of a hardcoded `.augment` literal, and the +`applyRuntimeContentRewritesForCommandsInPlace` command-body conversion +dispatch now reads `runtime.hostBehaviors.commandBodyConverter` +(`"convertClaudeToAugmentMarkdown"`) instead of a hardcoded +`runtime === 'augment'` branch. Two dead-code sites were also removed from +`bin/install.js`: the orphaned `claudeToAugmentTools` map (superseded by the +single-sourced converters per ADR-1508 / #1675) and the unreachable +`else if (isAugment) { content = convertClaudeAgentToAugmentAgent(content); }` +inline agent-conversion branch (augment has been on the descriptor-agents path +since `_DESCRIPTOR_AGENTS_RUNTIMES` was introduced, making that `if`/`else if` +arm dead). **UPGRADE 3 — MCP companion config** +(`mergeGsdMcpServerIntoSettings`) registers the `gsd` MCP server directly +inside the same `settings.json` `mcpServers` block GSD's own hook +registration already writes (Augment hosts MCP in `settings.json`, unlike +Antigravity's standalone `mcp_config.json`) — non-destructively preserving +any other user-configured `mcpServers` entries; uninstall removes only the +GSD-owned `gsd` entry. `settings.json` is golden-excluded +(`HOOK_CONFIG_FILES`), so this upgrade produces no golden fixture change. +Source-grep guard + fail-closed negotiation coverage is in +`tests/declarative-reference-augment.test.cjs`; the dispatch/hook-bus/MCP +upgrade coverage is in `tests/augment-upgrades.test.cjs`. + --- ## qwen diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index f1899a7ab..e5c62b7a7 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -360,6 +360,10 @@ const capabilities = { "writesSharedSettings": true, "permissionWriter": null, "extendedHookEvents": [], + "hostBehaviors": { + "commandBodyConverter": "convertClaudeToAugmentMarkdown", + "mcpCompanion": "settings-json" + }, "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", @@ -3953,6 +3957,10 @@ const runtimes = { "writesSharedSettings": true, "permissionWriter": null, "extendedHookEvents": [], + "hostBehaviors": { + "commandBodyConverter": "convertClaudeToAugmentMarkdown", + "mcpCompanion": "settings-json" + }, "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index c1e2f6576..0a235a09b 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1107,6 +1107,11 @@ function convertClaudeToAugmentMarkdown(content) { return converted; } +// #2097 (ADR-1239): command-body converters selected by descriptor +// (runtime.hostBehaviors.commandBodyConverter) instead of a runtime-name +// branch. Degrade-closed: unknown/absent name → no conversion. +const COMMAND_BODY_CONVERTERS = { convertClaudeToAugmentMarkdown }; + function getAugmentSkillAdapterHeader(skillName) { return ` ## A. Skill Invocation @@ -2442,19 +2447,24 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a break; } - case 'augment': + case 'augment': { content = content.replace(/~\/\.claude\//g, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`); - content = content.replace(/~\/\.augment\//g, pathPrefix); - content = content.replace(/\$HOME\/\.augment\//g, pathPrefix); - content = content.replace(/~\/\.augment(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.augment(?![\w-])/g, normalizedPathPrefix); + // #2097: dot-dir self-references (~/.augment/…) → resolved prefix, + // dirName-derived (no runtime literal). getDirName('augment') resolves + // to '.augment', so this is byte-identical to the prior hardcoded regexes. + const _dd = escapeRegExp(dirName); + content = content.replace(new RegExp('~/' + _dd + '/', 'g'), pathPrefix); + content = content.replace(new RegExp('\\$HOME/' + _dd + '/', 'g'), pathPrefix); + content = content.replace(new RegExp('~/' + _dd + '(?![\\w-])', 'g'), normalizedPathPrefix); + content = content.replace(new RegExp('\\$HOME/' + _dd + '(?![\\w-])', 'g'), normalizedPathPrefix); content = processAttribution(content, attribution); break; + } case 'trae': content = content.replace(/~\/\.claude\//g, pathPrefix); @@ -2631,8 +2641,11 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP if (!entry.isFile() || !entry.name.endsWith('.md')) continue; let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8'); content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, attribution); - if (runtime === 'augment') { - content = convertClaudeToAugmentMarkdown(content); + // #2097 (ADR-1239): descriptor-driven — commandBodyConverter name comes + // from runtime.hostBehaviors instead of a hardcoded runtime-name branch. + const _cmdConv = _hostBehaviors(runtime).commandBodyConverter; + if (_cmdConv && COMMAND_BODY_CONVERTERS[_cmdConv]) { + content = COMMAND_BODY_CONVERTERS[_cmdConv](content); } fs.writeFileSync(path.join(tempDir, entry.name), content); } diff --git a/tests/augment-upgrades.test.cjs b/tests/augment-upgrades.test.cjs new file mode 100644 index 000000000..323d902ae --- /dev/null +++ b/tests/augment-upgrades.test.cjs @@ -0,0 +1,243 @@ +'use strict'; + +/** + * Augment Code capability UPGRADES — ADR-1239 / #2097 (EoS/augment migration). + * + * Mirrors tests/antigravity-upgrades.test.cjs's structure. Augment already + * shipped as a declarative-CLI host prior to #2097 (nested-skill artifact + * layout, settings-json hook surface, Claude hook event dialect, dispatch + * namedDispatch/background/subagentToolkit:'full'), so UPGRADE 1 (dispatch) + * and UPGRADE 2 (hook-bus) below are REGRESSION locks on that pre-existing + * wiring, not new capabilities. UPGRADE 3 (MCP companion) IS new: #2097 adds + * `mergeGsdMcpServerIntoSettings`, registering the GSD companion MCP server + * inside Augment's own settings.json `mcpServers` block (Augment hosts MCP + * there, unlike Antigravity's standalone mcp_config.json) — non-destructively + * appending GSD's own entry while preserving any other user-configured + * `mcpServers` entries. Uninstall removes only the GSD-owned `gsd` entry. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +const { runMinimalInstall, installerEnv, INSTALL_SCRIPT } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { + mergeGsdMcpServerIntoSettings, +} = require('../bin/install.js'); +const { negotiateHostCapabilities } = require('../gsd-core/bin/lib/host-integration.cjs'); +const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs'); + +const AUGMENT_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'augment', 'capability.json'), 'utf8'), +); + +// --------------------------------------------------------------------------- +// UPGRADE 1: dispatch — descriptor + negotiation regression lock +// --------------------------------------------------------------------------- + +test('capabilities/augment/capability.json declares dispatch.namedDispatch/background/subagentToolkit for full subagent support (UPGRADE 1)', () => { + const dispatch = AUGMENT_CAP.runtime.hostIntegration.dispatch; + assert.equal(dispatch.namedDispatch, true); + assert.equal(dispatch.background, true); + assert.equal(dispatch.subagentToolkit, 'full'); +}); + +test('negotiateHostCapabilities does NOT flatten dispatch for augment — namedDispatch/background/subagentToolkit survive negotiation (UPGRADE 1)', () => { + const { effective } = negotiateHostCapabilities(AUGMENT_CAP.runtime.hostIntegration); + assert.equal(effective.dispatch.namedDispatch, true, + 'documented namedDispatch:true must survive negotiation unflattened'); + assert.equal(effective.dispatch.background, true, + 'documented background:true must survive negotiation unflattened (not capped to false)'); + assert.equal(effective.dispatch.subagentToolkit, 'full', + 'documented subagentToolkit:full must survive negotiation unflattened'); +}); + +// --------------------------------------------------------------------------- +// UPGRADE 2: hook-bus — live install proves the settings.json hook surface +// (SessionStart/PostToolUse/PreToolUse) is actually wired for augment. +// --------------------------------------------------------------------------- + +for (const scope of ['global', 'local']) { + test(`augment --${scope}: settings.json contains GSD's managed hook entries (UPGRADE 2)`, (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'augment', scope }); + t.after(() => cleanup(root)); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(settings.hooks && typeof settings.hooks === 'object', 'settings.hooks must be an object'); + + // Augment's hookEvents dialect is 'claude' (capabilities/augment/capability.json), + // so it gets the Claude-dialect event names (PostToolUse/PreToolUse), not + // antigravity's gemini-dialect (AfterTool/BeforeTool). + assert.ok(Array.isArray(settings.hooks.SessionStart) && settings.hooks.SessionStart.length > 0, + 'settings.hooks.SessionStart must be registered'); + assert.ok(Array.isArray(settings.hooks.PostToolUse) && settings.hooks.PostToolUse.length > 0, + 'settings.hooks.PostToolUse must be registered'); + assert.ok(Array.isArray(settings.hooks.PreToolUse) && settings.hooks.PreToolUse.length > 0, + 'settings.hooks.PreToolUse must be registered'); + + const hasGsdCommand = (entries) => entries.some( + (entry) => entry && Array.isArray(entry.hooks) && + entry.hooks.some((h) => h && typeof h.command === 'string' && h.command.includes('gsd-')), + ); + assert.ok(hasGsdCommand(settings.hooks.SessionStart), 'SessionStart must reference a gsd- managed hook'); + assert.ok(hasGsdCommand(settings.hooks.PostToolUse), 'PostToolUse must reference a gsd- managed hook'); + assert.ok(hasGsdCommand(settings.hooks.PreToolUse), 'PreToolUse must reference a gsd- managed hook'); + }); +} + +// --------------------------------------------------------------------------- +// UPGRADE 3: MCP companion config — direct-unit idempotency + preservation +// (mirrors antigravity's configureAntigravityMcpConfig unit tests exactly, +// scoped to the in-memory settings object mergeGsdMcpServerIntoSettings mutates). +// --------------------------------------------------------------------------- + +test('mergeGsdMcpServerIntoSettings adds mcpServers.gsd with command "npx" and args including gsd-mcp-server + PACKAGE_NAME', () => { + const settings = {}; + mergeGsdMcpServerIntoSettings(settings); + assert.ok(settings.mcpServers && settings.mcpServers.gsd, 'mcpServers.gsd must be present'); + assert.equal(settings.mcpServers.gsd.command, 'npx'); + assert.deepEqual(settings.mcpServers.gsd.args, ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server']); +}); + +test('mergeGsdMcpServerIntoSettings is idempotent — a second call does not clobber an existing gsd entry', () => { + const settings = {}; + mergeGsdMcpServerIntoSettings(settings); + // Simulate a user hand-edit of the gsd entry after install. + settings.mcpServers.gsd.args.push('--custom-flag'); + + mergeGsdMcpServerIntoSettings(settings); + + assert.ok(settings.mcpServers.gsd.args.includes('--custom-flag'), + "a user-owned gsd override is never clobbered (Hyrum's Law)"); + assert.equal(Object.keys(settings.mcpServers).length, 1, 'exactly one mcpServers entry (gsd)'); +}); + +test('mergeGsdMcpServerIntoSettings preserves a pre-existing unrelated mcpServers entry', () => { + const settings = { + mcpServers: { + 'my-own-server': { command: 'my-tool', args: ['--flag'] }, + }, + }; + mergeGsdMcpServerIntoSettings(settings); + + assert.deepEqual(settings.mcpServers['my-own-server'], { command: 'my-tool', args: ['--flag'] }); + assert.ok(settings.mcpServers.gsd); + assert.equal(Object.keys(settings.mcpServers).length, 2, 'both entries present'); +}); + +test('mergeGsdMcpServerIntoSettings recovers a corrupted (non-object) mcpServers field', () => { + const settingsArrayCase = { mcpServers: ['not', 'an', 'object'] }; + mergeGsdMcpServerIntoSettings(settingsArrayCase); + assert.ok(settingsArrayCase.mcpServers.gsd, 'array mcpServers must be recovered to an object with gsd set'); + + const settingsStringCase = { mcpServers: 'corrupted-string' }; + mergeGsdMcpServerIntoSettings(settingsStringCase); + assert.ok(settingsStringCase.mcpServers.gsd, 'non-object mcpServers must be recovered to an object with gsd set'); +}); + +// --------------------------------------------------------------------------- +// UPGRADE 3: MCP companion config — live install (both scopes) proves the +// end-to-end wiring through finishInstall, not just the unit function. +// --------------------------------------------------------------------------- + +for (const scope of ['global', 'local']) { + test(`augment --${scope}: settings.json registers the gsd MCP companion (UPGRADE 3)`, (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'augment', scope }); + t.after(() => cleanup(root)); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(settings.mcpServers && settings.mcpServers.gsd, 'mcpServers.gsd must be present'); + assert.equal(settings.mcpServers.gsd.command, 'npx'); + assert.deepEqual(settings.mcpServers.gsd.args, ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server']); + }); +} + +test('augment --global: reinstalling does not duplicate or clobber the gsd MCP companion entry (live-install idempotency)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-augment-mcp-idem-')); + t.after(() => cleanup(root)); + + const args = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root]; + const env = installerEnv({ HOME: root, USERPROFILE: root }); + + const first = spawnSync(process.execPath, args, { encoding: 'utf8', env }); + assert.strictEqual(first.status, 0, `first install failed: ${first.stderr}`); + + const settingsPath = path.join(root, 'settings.json'); + const afterFirst = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(afterFirst.mcpServers.gsd); + // Simulate a user hand-edit of the gsd entry between installs. + afterFirst.mcpServers.gsd.args.push('--custom-flag'); + fs.writeFileSync(settingsPath, JSON.stringify(afterFirst, null, 2) + '\n'); + + const second = spawnSync(process.execPath, args, { encoding: 'utf8', env }); + assert.strictEqual(second.status, 0, `second install failed: ${second.stderr}`); + + const afterSecond = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.equal(Object.keys(afterSecond.mcpServers).length, 1, 'reinstall must not duplicate the gsd entry'); + assert.ok(afterSecond.mcpServers.gsd.args.includes('--custom-flag'), + "a user-owned gsd override is never clobbered across reinstall (Hyrum's Law)"); +}); + +test('augment --global: installing preserves a pre-existing unrelated mcpServers entry (live-install preservation)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-augment-mcp-preserve-')); + fs.mkdirSync(root, { recursive: true }); + t.after(() => cleanup(root)); + + // Pre-seed settings.json with a user's own MCP server BEFORE install runs. + fs.writeFileSync(path.join(root, 'settings.json'), JSON.stringify({ + mcpServers: { + other: { command: 'my-tool', args: ['--flag'] }, + }, + }, null, 2)); + + const args = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root]; + const result = spawnSync(process.execPath, args, { + encoding: 'utf8', + env: installerEnv({ HOME: root, USERPROFILE: root }), + }); + assert.strictEqual(result.status, 0, `install failed: ${result.stderr}`); + + const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); + assert.deepEqual(settings.mcpServers.other, { command: 'my-tool', args: ['--flag'] }, + "the user's pre-existing mcpServers entry must be preserved"); + assert.ok(settings.mcpServers.gsd, 'the gsd companion entry must also be present'); +}); + +// --------------------------------------------------------------------------- +// Uninstall — symmetric cleanup for the MCP companion entry. +// --------------------------------------------------------------------------- + +test('augment --global uninstall removes only the GSD-owned mcpServers.gsd entry, preserving user data', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-augment-uninstall-')); + t.after(() => cleanup(root)); + + const env = installerEnv({ HOME: root, USERPROFILE: root }); + const installArgs = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root]; + const installResult = spawnSync(process.execPath, installArgs, { encoding: 'utf8', env }); + assert.strictEqual(installResult.status, 0, `install failed: ${installResult.stderr}`); + + // Seed user-owned data alongside GSD's contributions, post-install. + const settingsPath = path.join(root, 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + settings.mcpServers['my-own-server'] = { command: 'my-tool', args: [] }; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + const uninstallArgs = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root, '--uninstall']; + const uninstallResult = spawnSync(process.execPath, uninstallArgs, { encoding: 'utf8', env }); + assert.strictEqual(uninstallResult.status, 0, `uninstall failed: ${uninstallResult.stderr}`); + + const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.equal(settingsAfter.mcpServers && settingsAfter.mcpServers.gsd, undefined, 'gsd MCP entry removed'); + assert.deepEqual(settingsAfter.mcpServers['my-own-server'], { command: 'my-tool', args: [] }, + 'user MCP server preserved'); +}); diff --git a/tests/declarative-reference-augment.test.cjs b/tests/declarative-reference-augment.test.cjs new file mode 100644 index 000000000..68bfe52e9 --- /dev/null +++ b/tests/declarative-reference-augment.test.cjs @@ -0,0 +1,202 @@ +// allow-test-rule: structural-regression-guard — the descriptor-migration contract (no `runtime === 'augment'` / `isAugment`-as-conversion-branch dispatch logic left in the folded modules) is a property of the source text itself, so a source-grep is the only faithful check (#2097, mirrors tests/declarative-reference-antigravity.test.cjs #2096 AC2 guard). +'use strict'; + +/** + * Declarative reference host coverage — Augment Code (ADR-1239 / #2097 + * EoS/augment migration). + * + * Augment is already a declarative-CLI host (nested-skill artifact layout, + * settings-json hook surface, Claude hook event dialect). #2097 folds its two + * remaining runtime-literal branches in src/runtime-artifact-conversion.cts + * onto descriptor-driven dispatch (runtime.hostBehaviors.commandBodyConverter) + * and deletes 2 dead-code sites in bin/install.js — mirroring the Antigravity + * migration (#2096) structure without duplicating its Antigravity-specific + * (GEMINI.md / reviewerCli / noPathRewrite) assertions. + * + * UPGRADE 3 (MCP companion, settings.json-hosted) live-install coverage is in + * tests/augment-upgrades.test.cjs — not duplicated here. + */ + +const { test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { + profileOf, + negotiateHostCapabilities, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); +const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); + +const DESC = path.join(__dirname, '..', 'capabilities', 'augment', 'capability.json'); +const AUGMENT_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); +const AUGMENT_AXES = AUGMENT_CAP.runtime.hostIntegration; + +// hooks/dist is gitignored and built (mirrors golden-install-parity harness). +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); +}); + +test('Augment classifies as the declarative-cli profile (profileOf)', () => { + const desc = JSON.parse(fs.readFileSync(DESC, 'utf8')); + const axes = desc.runtime.hostIntegration; + assert.ok(axes && axes.embeddingMode, 'augment descriptor declares hostIntegration axes'); + assert.equal(profileOf(axes), 'declarative-cli', 'Augment is a declarative-CLI host'); +}); + +test('the public declarative adapter classifies Augment as a declarative host', () => { + const adapter = createDeclarativeAdapter({ runtime: 'augment' }); + assert.equal(adapter.kind, 'declarative'); + assert.equal(adapter.runtime, 'augment'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('a real Augment install emits a gsd command/skill surface (invocable)', () => { + const { configDir, root } = runMinimalInstall({ runtime: 'augment', scope: 'global' }); + try { + const files = walk(configDir); + assert.ok(files.length > 0, 'install must emit artifacts'); + const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f))); + assert.ok(gsdSurface.length > 0, + 'install must emit a gsd command/skill surface (declarative reference)'); + } finally { + cleanup(root); + } +}); + +// --------------------------------------------------------------------------- +// #2097 EoS/augment — fail-closed negotiation + folded dispatch sub-axes +// (mirrors the antigravity/kimi reference tests). +// --------------------------------------------------------------------------- + +test('negotiateHostCapabilities never throws for augment, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, embeddingMode: 'future-unknown' })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, dispatch: 'corrupted-not-an-object' })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, dispatch: { ...AUGMENT_AXES.dispatch, maxDepth: 'not-a-number' } })); +}); + +// AC-style proof: the 3 still-`undocumented` dispatch sub-axes (nested/ +// maxDepth/backgroundDispatch) must degrade to the most-restrictive KNOWN +// value, never their optimistic value — mirrors antigravity's equivalent +// test, but Augment documents namedDispatch/background (unlike antigravity), +// so only 3 sub-axes are undocumented here, not 4. Real values confirmed via: +// node -e "const {negotiateHostCapabilities}=require('./gsd-core/bin/lib/host-integration.cjs'); +// const cap=require('./capabilities/augment/capability.json'); +// console.log(negotiateHostCapabilities(cap.runtime.hostIntegration).effective.dispatch)" +// -> { namedDispatch:true, nested:false, maxDepth:0, background:true, subagentToolkit:'full', backgroundDispatch:false } +test("augment's 3 still-undocumented dispatch sub-axes (nested/maxDepth/backgroundDispatch) degrade to the most-restrictive known value, not their optimistic value", () => { + // Sanity: the descriptor itself still declares these 3 as the undocumented + // sentinel, while namedDispatch/background/subagentToolkit are documented. + assert.equal(AUGMENT_AXES.dispatch.nested, 'undocumented'); + assert.equal(AUGMENT_AXES.dispatch.maxDepth, 'undocumented'); + assert.equal(AUGMENT_AXES.dispatch.backgroundDispatch, 'undocumented'); + assert.equal(AUGMENT_AXES.dispatch.namedDispatch, true, 'sanity: namedDispatch is documented, not part of the undocumented set'); + assert.equal(AUGMENT_AXES.dispatch.background, true, 'sanity: background is documented, not part of the undocumented set'); + assert.equal(AUGMENT_AXES.dispatch.subagentToolkit, 'full', 'sanity: subagentToolkit is documented, not part of the undocumented set'); + + const { effective, warnings } = negotiateHostCapabilities(AUGMENT_AXES); + + assert.equal(effective.dispatch.nested, false, 'undocumented nested must degrade to false, never true'); + assert.equal(effective.dispatch.maxDepth, 0, 'undocumented maxDepth must degrade to 0, never -1/unbounded'); + assert.equal(effective.dispatch.backgroundDispatch, false, 'undocumented backgroundDispatch must degrade to false, never true'); + + // namedDispatch/background/subagentToolkit are documented — they are + // trusted and survive negotiation unchanged, in contrast to the 3 + // undocumented sub-axes above. Augment's negotiation must NOT flatten + // dispatch the way a fully-undocumented descriptor (e.g. antigravity's + // namedDispatch) would. + assert.equal(effective.dispatch.namedDispatch, true, "documented 'true' namedDispatch is trusted, unlike the undocumented sub-axes"); + assert.equal(effective.dispatch.background, true, "documented 'true' background is trusted — not capped to false, since namedDispatch did not degrade closed"); + assert.equal(effective.dispatch.subagentToolkit, 'full', "documented 'full' subagentToolkit is trusted, unlike the undocumented sub-axes"); + + for (const axis of ['nested', 'backgroundDispatch']) { + assert.ok( + warnings.some((w) => w.includes(`dispatch.${axis}`) && w.includes('undocumented')), + `a warning must be raised for the undocumented dispatch.${axis} axis`, + ); + } + assert.ok( + warnings.some((w) => w.includes('dispatch.maxDepth')), + 'a warning must be raised for the undocumented dispatch.maxDepth axis (reported as missing/non-number)', + ); +}); + +// --------------------------------------------------------------------------- +// #2097 AC2-equivalent — the hardcoded conversion-dispatch branches folded by +// this migration are retired from the folded modules. +// +// Unlike antigravity (#2096), where `isAntigravity` was eliminated from +// bin/install.js entirely, Augment's `isAugment` flag legitimately remains +// (destructured from runtimeFlags() and used for non-conversion verification +// logic — e.g. counting installed commands/ files). So this guard does NOT +// assert zero `isAugment` occurrences (that would be a false failure); it +// asserts no `isAugment` occurrence gates a call to an Augment *converter* +// function (the exact shape of the dead branch this migration deleted: +// `else if (isAugment) { content = convertClaudeAgentToAugmentAgent(content); }`). +// The switch `case 'augment':` label in _applyRuntimeRewrites is intentionally +// NOT flagged — it does plain dirName-derived path-prefix rewriting (same as +// the still-`case`-labeled trae/codebuddy/cursor/windsurf branches), not +// conversion-function dispatch, and antigravity's own guard tolerates its +// `case 'antigravity':` label for the identical reason (its regex only +// matches `runtime === 'x'`, never a `case` label). +// --------------------------------------------------------------------------- + +test('no `runtime === "augment"` string-equality branch remains in the descriptor-migrated modules', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + const repoRoot = path.join(__dirname, '..'); + const files = [ + path.join(repoRoot, 'bin', 'install.js'), + path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'), + ]; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + const stripped = strip(src); + const eqOffenders = stripped.match(/runtime\s*[!=]==\s*'augment'/g) || []; + assert.deepEqual(eqOffenders, [], + `no hardcoded runtime==='augment' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`); + } +}); + +test('no `isAugment` occurrence gates a call to an Augment converter function (the deleted dead-code shape)', () => { + const converterNames = [ + 'convertClaudeAgentToAugmentAgent', + 'convertClaudeToAugmentMarkdown', + 'convertClaudeCommandToAugmentSkill', + 'convertSlashCommandsToAugmentSkillMentions', + ]; + const converterCallPattern = new RegExp(`(${converterNames.join('|')})\\s*\\(`); + + const file = path.join(__dirname, '..', 'bin', 'install.js'); + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + const offenders = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Only condition-position occurrences count (`if (isAugment)` / `else if + // (isAugment)`), not the runtimeFlags() destructure or unrelated reads. + if (!/(?:if|else if)\s*\(\s*isAugment\s*\)/.test(line)) continue; + const window = lines.slice(i, i + 4).join('\n'); + if (converterCallPattern.test(window)) { + offenders.push(`line ${i + 1}: ${line.trim()}`); + } + } + assert.deepEqual(offenders, [], + `no isAugment-gated call to an Augment converter may remain in bin/install.js; found: ${offenders.join(' | ')}`); +}); + +test('legitimate isAugment destructure/enumeration sites survive (not eliminated, unlike isAntigravity)', () => { + const file = path.join(__dirname, '..', 'bin', 'install.js'); + const src = fs.readFileSync(file, 'utf8'); + assert.ok(/isAugment/.test(src), 'isAugment must still be destructured from runtimeFlags() for non-conversion uses'); + assert.ok(/_DESCRIPTOR_AGENTS_RUNTIMES\s*=\s*new Set\(\[[^\]]*'augment'/.test(src), + 'augment must remain in _DESCRIPTOR_AGENTS_RUNTIMES (descriptor-driven agent layout)'); +});