Merge pull request #2166 from open-gsd/feat/2097-eos-augment
feat(#2097): migrate Augment onto EoS declarative adapter + settings.json MCP companion (ADR-1239)
This commit is contained in:
6
.changeset/2097-eos-augment-imperative-adapter.md
Normal file
6
.changeset/2097-eos-augment-imperative-adapter.md
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": [],
|
||||
"hostBehaviors": {
|
||||
"commandBodyConverter": "convertClaudeToAugmentMarkdown",
|
||||
"mcpCompanion": "settings-json"
|
||||
},
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<augment_skill_adapter>
|
||||
## 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);
|
||||
}
|
||||
|
||||
243
tests/augment-upgrades.test.cjs
Normal file
243
tests/augment-upgrades.test.cjs
Normal file
@@ -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');
|
||||
});
|
||||
202
tests/declarative-reference-augment.test.cjs
Normal file
202
tests/declarative-reference-augment.test.cjs
Normal file
@@ -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)');
|
||||
});
|
||||
Reference in New Issue
Block a user