@aiwg/cli 2026.10.1 → 2026.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +270 -0
  2. package/agentic/code/providers/model-capabilities.v1.json +3091 -24
  3. package/bin/aiwg.mjs +86 -21
  4. package/dist/src/a2a/codecs.js +9 -0
  5. package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
  6. package/dist/src/artifacts/repair.js +9 -4
  7. package/dist/src/channel/manager.mjs +59 -10
  8. package/dist/src/cli/child-process-result.js +40 -0
  9. package/dist/src/cli/env.js +38 -16
  10. package/dist/src/cli/flag-parsing.js +236 -0
  11. package/dist/src/cli/handlers/decision.js +210 -4
  12. package/dist/src/cli/handlers/diagnose.js +9 -2
  13. package/dist/src/cli/handlers/feedback.js +14 -5
  14. package/dist/src/cli/handlers/init.js +245 -30
  15. package/dist/src/cli/handlers/mc.js +23 -0
  16. package/dist/src/cli/handlers/models.js +69 -6
  17. package/dist/src/cli/handlers/ralph.js +3 -0
  18. package/dist/src/cli/handlers/refresh.js +16 -0
  19. package/dist/src/cli/handlers/regenerate.js +14 -0
  20. package/dist/src/cli/handlers/runtime-info.js +24 -9
  21. package/dist/src/cli/handlers/script-runner.js +16 -6
  22. package/dist/src/cli/handlers/session.js +396 -62
  23. package/dist/src/cli/handlers/sessions.js +146 -40
  24. package/dist/src/cli/handlers/setup-manifest.js +439 -39
  25. package/dist/src/cli/handlers/setup.js +139 -39
  26. package/dist/src/cli/handlers/subcommands.js +3 -1
  27. package/dist/src/cli/handlers/team.js +18 -8
  28. package/dist/src/cli/handlers/use.js +13 -0
  29. package/dist/src/cli/handlers/utilities.js +106 -23
  30. package/dist/src/cli/prompt-utils.js +72 -0
  31. package/dist/src/cli/provider-resolution.js +11 -0
  32. package/dist/src/config/aiwg-config.js +268 -4
  33. package/dist/src/config/cli.js +146 -19
  34. package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
  35. package/dist/src/config/project-artifacts-health.mjs +14 -3
  36. package/dist/src/config/user-config.js +36 -10
  37. package/dist/src/decision/adapters/jev.js +75 -27
  38. package/dist/src/decision/driver.js +3 -1
  39. package/dist/src/decision/entry.js +38 -6
  40. package/dist/src/decision/heldout/collector.js +10 -1
  41. package/dist/src/decision/index.js +1 -0
  42. package/dist/src/decision/interop.js +60 -8
  43. package/dist/src/decision/ledger.js +839 -0
  44. package/dist/src/decision/validate.js +6 -3
  45. package/dist/src/effects/dispatch-verifier.js +252 -0
  46. package/dist/src/effects/index.js +1 -0
  47. package/dist/src/features/installer.js +2 -2
  48. package/dist/src/flow/graph-metadata.js +5 -0
  49. package/dist/src/gates/providers/loader.js +3 -1
  50. package/dist/src/installation/manager-command.mjs +1 -1
  51. package/dist/src/installation/manager.mjs +17 -2
  52. package/dist/src/lint/runner.js +2 -2
  53. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  54. package/dist/src/mcp/cli.mjs +587 -77
  55. package/dist/src/mcp/config-file.mjs +279 -0
  56. package/dist/src/mcp/config-layers.mjs +348 -0
  57. package/dist/src/mcp/credentials.mjs +484 -0
  58. package/dist/src/mcp/grok-build-config.mjs +16 -26
  59. package/dist/src/mcp/helpers.mjs +106 -2
  60. package/dist/src/mcp/omp-config.mjs +50 -29
  61. package/dist/src/mcp/profiles.js +97 -23
  62. package/dist/src/mcp/profiles.mjs +99 -27
  63. package/dist/src/mcp/registry.js +264 -57
  64. package/dist/src/mcp/registry.mjs +198 -45
  65. package/dist/src/mcp/server.mjs +403 -20
  66. package/dist/src/mcp/toml-string.mjs +9 -0
  67. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  68. package/dist/src/mcp/tool-filters.mjs +357 -0
  69. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  70. package/dist/src/mcp/tools/decision.mjs +21 -3
  71. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  72. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  73. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  74. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  75. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  76. package/dist/src/memory/project-registry.js +2 -2
  77. package/dist/src/mission-protocol/codecs.js +639 -1
  78. package/dist/src/mission-protocol/types.js +110 -0
  79. package/dist/src/mission-protocol/worker-status.js +2 -0
  80. package/dist/src/models/config-loader.js +70 -13
  81. package/dist/src/models/grok-build-models.js +93 -41
  82. package/dist/src/models/model-capabilities.v1.json +3091 -24
  83. package/dist/src/models/model-discovery.js +862 -63
  84. package/dist/src/models/provider-policy.js +625 -11
  85. package/dist/src/models/qualification-digest.mjs +17 -0
  86. package/dist/src/models/resolver.js +259 -0
  87. package/dist/src/models/worker-direct-readiness.js +529 -0
  88. package/dist/src/models/worker-dispatch-offer.js +169 -0
  89. package/dist/src/models/worker-health-readiness.js +131 -0
  90. package/dist/src/models/worker-installation.mjs +879 -0
  91. package/dist/src/models/wrapper-route.js +18 -0
  92. package/dist/src/ops/registry.js +9 -14
  93. package/dist/src/plugins/standalone-packager.js +4 -3
  94. package/dist/src/process/bounded-child-process.mjs +387 -0
  95. package/dist/src/process/cli-command.mjs +75 -0
  96. package/dist/src/providers/capability-matrix.js +8 -0
  97. package/dist/src/providers/capability-matrix.yaml +270 -0
  98. package/dist/src/providers/grok-build-headless.js +43 -21
  99. package/dist/src/providers/provider-definitions.js +9 -1
  100. package/dist/src/providers/provider-definitions.mjs +9 -2
  101. package/dist/src/serve/executor-registry.js +16 -24
  102. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  103. package/dist/src/serve/mission-conductor.js +9 -0
  104. package/dist/src/serve/shared-host-scheduler.js +2 -0
  105. package/dist/src/serve/worker-invocation.js +538 -0
  106. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  107. package/dist/src/sessions/adapter-registry.js +45 -0
  108. package/dist/src/sessions/adapters/generic.js +37 -3
  109. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  110. package/dist/src/sessions/adapters/grokbot.js +423 -24
  111. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  112. package/dist/src/sessions/adapters/muse.js +68 -29
  113. package/dist/src/sessions/adapters/pi.js +1 -0
  114. package/dist/src/sessions/batch-contracts.js +13 -1
  115. package/dist/src/sessions/batch-import.js +23 -42
  116. package/dist/src/sessions/candidates.js +47 -11
  117. package/dist/src/sessions/contracts.js +35 -0
  118. package/dist/src/sessions/coverage-preflight.js +134 -0
  119. package/dist/src/sessions/discovery.js +3 -0
  120. package/dist/src/sessions/grok-build-cli.js +67 -5
  121. package/dist/src/sessions/importer.js +24 -8
  122. package/dist/src/sessions/index.js +2 -0
  123. package/dist/src/sessions/repository.js +54 -13
  124. package/dist/src/sessions/workspace-discovery.js +99 -37
  125. package/dist/src/skills/adapters/clawhub.js +10 -8
  126. package/dist/src/skills/adapters/openclaw.js +10 -7
  127. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  128. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  129. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  130. package/dist/src/smiths/mcpsmith/example.js +1 -1
  131. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  132. package/dist/src/storage/backends/logseq.js +68 -7
  133. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  134. package/dist/src/testing/generators/test-code-generator.js +57 -30
  135. package/dist/src/uhp/client.js +11 -1
  136. package/dist/src/uhp/mission.js +5 -0
  137. package/dist/src/update/checker.mjs +5 -5
  138. package/dist/src/update/notifier.mjs +54 -2
  139. package/dist/src/writing/fidelity.js +18 -1
  140. package/package.json +18 -1
  141. package/schemas/mission-v1.schema.json +2429 -0
  142. package/schemas/models/model-catalog.v1.schema.json +301 -0
  143. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  144. package/schemas/models/model-policy.v1.schema.json +14 -0
  145. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  146. package/tools/_resolve-impl.mjs +39 -0
  147. package/tools/agents/deploy-agents.mjs +15 -2
  148. package/tools/agents/providers/base.mjs +49 -32
  149. package/tools/agents/providers/grokbot.mjs +11 -12
  150. package/tools/agents/providers/hermes.mjs +6 -3
  151. package/tools/agents/providers/omp.mjs +5 -2
  152. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  153. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  154. package/tools/providers/omp-transport.mjs +192 -0
  155. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  156. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  157. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  158. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  159. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  160. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  161. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  162. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  163. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  164. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  165. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  166. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
@@ -7,20 +7,145 @@
7
7
  */
8
8
 
9
9
  import { startServer, createServer } from './server.mjs';
10
+ import { assertConfigDestination, assertConfigObject, assertProjectCredentials, isPathInsideDir, classifyMcpDestination, projectCredentialRemedy, writeConfigAtomic, writeConfigTransaction } from './config-file.mjs';
10
11
  import fs from 'fs/promises';
11
12
  import path from 'path';
12
- import { fileURLToPath } from 'url';
13
+ import os from 'os';
14
+ import { existsSync as nodeExistsSync, readFileSync as nodeReadFileSync } from 'fs';
15
+ import { fileURLToPath, pathToFileURL } from 'url';
13
16
  import {
14
17
  McpServerRegistry,
18
+ buildServerConfig,
15
19
  injectServers,
16
20
  SUPPORTED_PROVIDERS,
17
21
  getProviderConfigPath,
18
22
  } from './registry.mjs';
19
23
  import { McpProfileRegistry } from './profiles.mjs';
24
+ import {
25
+ applyJsonToolFilterPlan,
26
+ hasToolFilters,
27
+ planToolFilters,
28
+ prepareClaudePermissions,
29
+ resolveToolFilters,
30
+ } from './tool-filters.mjs';
31
+ import { assertCredentialPolicy, CREDENTIAL_POLICIES, installPolicyCheckedServer, redactCredentialArgs, redactCredentialText, redactCredentialRecord, resolveCredentialPolicy } from './credentials.mjs';
20
32
  import { getMcpInjectionDefinition } from '../providers/provider-definitions.mjs';
21
33
  import { manageOmpMcp } from './omp-config.mjs';
22
34
  import { unmanageGrokBuildMcp } from './grok-build-config.mjs';
23
35
 
36
+ /**
37
+ * Thrown when a recognized flag is present but has no usable value
38
+ * following it (#3062 review item 11). Mirrors `src/cli/flag-parsing.ts`'s
39
+ * `MissingFlagValueError`/`requireFlagValue`, duplicated in plain JS rather
40
+ * than imported: this file is exercised both compiled (`dist/src/mcp/
41
+ * cli.mjs`, where a `../cli/flag-parsing.js` sibling import would resolve)
42
+ * and directly from source in tests (`src/mcp/cli.mjs`, where it would
43
+ * not — there is no compiled `.js` sibling under `src/` itself).
44
+ */
45
+ class MissingFlagValueError extends Error {
46
+ constructor(flagName) {
47
+ super(`${flagName} requires a value`);
48
+ this.name = 'MissingFlagValueError';
49
+ this.flagName = flagName;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Single-pass, every-occurrence-validated flag lookup (#3062 second
55
+ * review, finding 4 — mirrors `src/cli/flag-parsing.ts`'s
56
+ * `scanFlagOccurrences`/`requireFlagValue`, duplicated here for the same
57
+ * source/dist reason as the class above): stops at a bare `--`
58
+ * terminator; accepts `--flag value` and `--flag=value`; a space-
59
+ * separated value starting with `--` is treated as missing (use `=` for a
60
+ * value that must start with `--`); every occurrence is checked, not just
61
+ * the first; `options.rejectEmpty` additionally rejects an empty-string
62
+ * value. `options.precedence` picks which occurrence wins when the flag
63
+ * repeats — `'first'` (default) here matches this file's own pre-existing
64
+ * `parseFlag()` convention (first match wins).
65
+ */
66
+ function requireFlagValue(args, options, ...names) {
67
+ const occurrences = [];
68
+ for (let i = 0; i < args.length; i += 1) {
69
+ const token = args[i];
70
+ if (token === undefined) continue;
71
+ if (token === '--') break;
72
+
73
+ if (token.startsWith('--')) {
74
+ const eq = token.indexOf('=');
75
+ if (eq !== -1) {
76
+ const flag = token.slice(0, eq);
77
+ const value = token.slice(eq + 1);
78
+ if (names.includes(flag)) {
79
+ if (options.rejectEmpty && value === '') throw new MissingFlagValueError(flag);
80
+ occurrences.push(value);
81
+ continue;
82
+ }
83
+ }
84
+ }
85
+
86
+ if (names.includes(token)) {
87
+ const next = args[i + 1];
88
+ if (next === undefined || next.startsWith('--')) throw new MissingFlagValueError(token);
89
+ if (options.rejectEmpty && next === '') throw new MissingFlagValueError(token);
90
+ occurrences.push(next);
91
+ i += 1;
92
+ }
93
+ }
94
+ if (occurrences.length === 0) return undefined;
95
+ return options.precedence === 'last' ? occurrences[occurrences.length - 1] : occurrences[0];
96
+ }
97
+
98
+ /**
99
+ * Find the AIWG package root by walking up from THIS file's own location
100
+ * (never from `AIWG_ROOT`/`process.cwd()`, which name the *served project*,
101
+ * not necessarily where this module's own package lives). Depth-independent
102
+ * on purpose: this file ships at `src/mcp/cli.mjs` in a dev checkout and at
103
+ * `dist/src/mcp/cli.mjs` in the published package. Mirrors
104
+ * `src/mcp/tools/worker-invoke.mjs`'s own `findOwnPackageRoot` (and
105
+ * `tools/_resolve-impl.mjs`'s `findPackageRoot`).
106
+ */
107
+ function findOwnPackageRoot() {
108
+ let dir = path.dirname(fileURLToPath(import.meta.url));
109
+ for (let i = 0; i < 12; i += 1) {
110
+ const pkgPath = path.join(dir, 'package.json');
111
+ if (nodeExistsSync(pkgPath)) {
112
+ try {
113
+ const pkg = JSON.parse(nodeReadFileSync(pkgPath, 'utf8'));
114
+ if (pkg.name === 'aiwg' || pkg.name === '@aiwg/cli') return dir;
115
+ } catch { /* keep walking */ }
116
+ }
117
+ const parent = path.dirname(dir);
118
+ if (parent === dir) break;
119
+ dir = parent;
120
+ }
121
+ throw new Error('Could not locate the AIWG package root from mcp/cli.mjs');
122
+ }
123
+
124
+ /**
125
+ * Load the compiled `loginInProfile()` adapter (#3062 second review,
126
+ * finding 3). The first round routed this through `tools/_resolve-impl.mjs`,
127
+ * which falls back to importing the TypeScript *source* when no compiled
128
+ * `dist/src/` exists — but that source module's own internal imports
129
+ * (e.g. `../../cli/env.js`) have no `.js` sibling under `src/` either, so
130
+ * the fallback itself fails deep inside the adapter with an unrelated-
131
+ * looking `ERR_MODULE_NOT_FOUND`, on every Node version, not just ones
132
+ * without native TypeScript support. This loads the compiled module
133
+ * directly — never TypeScript source — and fails fast with one actionable
134
+ * line when it is missing, instead of attempting (and failing inside) a
135
+ * source fallback.
136
+ */
137
+ async function loadCodexRuntimeAdapter() {
138
+ const root = findOwnPackageRoot();
139
+ const compiledPath = path.join(root, 'dist', 'src', 'mcp', 'adapters', 'codex-runtime.js');
140
+ if (!nodeExistsSync(compiledPath)) {
141
+ throw new Error(
142
+ 'aiwg mcp profile login requires the built CLI (missing dist/src/mcp/adapters/codex-runtime.js). ' +
143
+ 'Use an installed `aiwg` for this command; a source checkout must be built first (see docs/contributing).',
144
+ );
145
+ }
146
+ return import(pathToFileURL(compiledPath).href);
147
+ }
148
+
24
149
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
150
 
26
151
  /**
@@ -42,6 +167,8 @@ Usage:
42
167
  aiwg mcp inject [opts] Inject servers into provider configs
43
168
  aiwg mcp uninject [opts] Remove unchanged AIWG-owned OMP server entries
44
169
  aiwg mcp profile <sub> Manage MCP profiles (named server subsets)
170
+ aiwg mcp credential-policy [literal|references|none]
171
+ Show or set the registry's default credential policy
45
172
 
46
173
  Server Options (for add/update):
47
174
  --url <url> Server URL (for http/sse types)
@@ -52,6 +179,8 @@ Server Options (for add/update):
52
179
  --headers <K=V,...> HTTP headers (comma-separated K=V pairs)
53
180
  --header-env <K=ENV,...>
54
181
  Resolve HTTP header values from environment variables
182
+ --env-from <K=ENV,...>
183
+ Resolve stdio server variables from environment variables
55
184
  --description <text> Optional description
56
185
 
57
186
  Inject Options:
@@ -60,6 +189,10 @@ Inject Options:
60
189
  --all Inject into all previously configured providers
61
190
  --servers <a,b,...> Only inject specific servers (comma-separated names)
62
191
  --dry-run Show what would change without writing
192
+ --strict-credentials Refuse servers with literal env/headers values; render
193
+ only --header-env/--env-from references
194
+ --no-credentials Refuse servers with any credential-bearing field,
195
+ references included
63
196
 
64
197
  Serve Options:
65
198
  --transport <type> Transport type: stdio (default), http
@@ -70,6 +203,8 @@ Examples:
70
203
  aiwg mcp add fortemi --url https://memory.s9.internal/mcp --type http
71
204
  aiwg mcp add fortemi-enterprise --url https://memory.example.internal/mcp --type http \
72
205
  --header-env Authorization=AIWG_FORTEMI_TOKEN
206
+ aiwg mcp add github --type stdio --command github-mcp-server \
207
+ --env-from GITHUB_PERSONAL_ACCESS_TOKEN=GITHUB_TOKEN
73
208
  aiwg mcp add gitea --url https://mcp-gitea.integrolabs.net/mcp
74
209
  aiwg mcp add mytools --type stdio --command npx --args mcp-server-mytools
75
210
 
@@ -96,20 +231,20 @@ Examples:
96
231
  /**
97
232
  * Generate MCP client configuration
98
233
  */
99
- async function generateConfig(target, projectDir = '.') {
234
+ async function generateConfig(target, projectDir = '.', scope = 'project') {
100
235
  const homeDir = process.env.HOME || process.env.USERPROFILE;
101
236
 
102
237
  const configs = {
103
238
  claude: {
104
- path: path.join(projectDir, '.claude/settings.local.json'),
239
+ path: scope === 'user' ? path.join(homeDir, '.claude.json') : path.join(projectDir, '.mcp.json'),
240
+ userScope: scope === 'user',
105
241
  content: {
106
242
  mcpServers: {
107
243
  aiwg: {
108
244
  command: 'aiwg',
109
245
  args: ['mcp', 'serve'],
110
- env: {
111
- AIWG_ROOT: process.env.AIWG_ROOT || '~/.local/share/ai-writing-guide'
112
- }
246
+ ...(scope === 'user' && process.env.AIWG_ROOT
247
+ ? { env: { AIWG_ROOT: process.env.AIWG_ROOT } } : {})
113
248
  }
114
249
  }
115
250
  }
@@ -133,6 +268,7 @@ async function generateConfig(target, projectDir = '.') {
133
268
  })
134
269
  },
135
270
  factory: {
271
+ userScope: projectDir === '.' || projectDir === 'global',
136
272
  // Factory stores MCP config at user level in ~/.factory/mcp.json
137
273
  // or project level in .factory/mcp.json
138
274
  path: projectDir === '.' || projectDir === 'global'
@@ -158,7 +294,8 @@ async function generateConfig(target, projectDir = '.') {
158
294
  },
159
295
  codex: {
160
296
  // Codex stores config in ~/.codex/config.toml (TOML format)
161
- path: path.join(homeDir, '.codex/config.toml'),
297
+ path: path.resolve(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'config.toml'),
298
+ userScope: true,
162
299
  // We generate TOML snippet to append, not JSON
163
300
  content: null,
164
301
  toml: `
@@ -185,8 +322,8 @@ enabled_tools = [
185
322
  let existing = '';
186
323
  try {
187
324
  existing = await fs.readFile(configPath, 'utf-8');
188
- } catch {
189
- // File doesn't exist
325
+ } catch (error) {
326
+ if (error.code !== 'ENOENT') throw error;
190
327
  }
191
328
 
192
329
  // Check if AIWG MCP already configured
@@ -198,8 +335,7 @@ enabled_tools = [
198
335
  // Append TOML config
199
336
  const updated = existing.trimEnd() + '\n' + tomlContent.trim() + '\n';
200
337
 
201
- await fs.mkdir(path.dirname(configPath), { recursive: true });
202
- await fs.writeFile(configPath, updated);
338
+ await writeConfigAtomic(configPath, updated, { userScope: true });
203
339
  console.log(`MCP configuration appended to: ${configPath}`);
204
340
  console.log(`\nTo use AIWG MCP server with Codex:`);
205
341
  console.log(` 1. Restart Codex CLI`);
@@ -209,12 +345,14 @@ enabled_tools = [
209
345
  },
210
346
  openai: {
211
347
  // Alias for codex
212
- path: path.join(homeDir, '.codex/config.toml'),
348
+ path: path.resolve(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'config.toml'),
349
+ userScope: true,
213
350
  alias: 'codex'
214
351
  },
215
352
  windsurf: {
216
353
  // Windsurf stores MCP config at ~/.codeium/windsurf/mcp_config.json
217
354
  path: path.join(homeDir, '.codeium/windsurf/mcp_config.json'),
355
+ userScope: true,
218
356
  content: {
219
357
  mcpServers: {
220
358
  aiwg: {
@@ -312,17 +450,21 @@ enabled_tools = [
312
450
 
313
451
  // Find existing config
314
452
  for (const loc of locations) {
453
+ await assertConfigDestination(loc, projectRoot);
315
454
  try {
316
455
  const rawContent = await fs.readFile(loc, 'utf-8');
317
- // Strip JSONC comments for parsing
318
- const jsonContent = rawContent
319
- .replace(/\/\/.*$/gm, '')
320
- .replace(/\/\*[\s\S]*?\*\//g, '');
456
+ // Strip JSONC comments while preserving strings such as URLs.
457
+ const jsonContent = rawContent.replace(
458
+ /"(?:\\.|[^"\\])*"|\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g,
459
+ match => match.startsWith('"') ? match : ' ',
460
+ );
321
461
  existing = JSON.parse(jsonContent);
462
+ assertConfigObject(existing, 'mcp');
322
463
  targetPath = loc;
323
464
  break;
324
- } catch {
325
- // Continue to next location
465
+ } catch (error) {
466
+ if (error instanceof SyntaxError) throw new Error(`Refusing to overwrite malformed MCP config ${loc}: invalid JSON`);
467
+ if (error.code !== 'ENOENT') throw error;
326
468
  }
327
469
  }
328
470
 
@@ -335,8 +477,7 @@ enabled_tools = [
335
477
  // Merge configuration
336
478
  const merged = mergeFunc(existing, content);
337
479
 
338
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
339
- await fs.writeFile(targetPath, JSON.stringify(merged, null, 2));
480
+ await writeConfigAtomic(targetPath, JSON.stringify(merged, null, 2), { userScope, projectRoot });
340
481
  console.log(`MCP configuration written to: ${targetPath}`);
341
482
  console.log(`\nTo use AIWG MCP server with OpenCode:`);
342
483
  console.log(` 1. Restart OpenCode`);
@@ -358,21 +499,61 @@ enabled_tools = [
358
499
  config = configs[config.alias];
359
500
  }
360
501
 
502
+ // Destination-derived, not scope-derived (and never from a static
503
+ // `config.userScope` flag): a target whose resolved path still lands inside
504
+ // the project directory gets project containment, whatever scope was
505
+ // requested. Providers with no real user-scope destination (Cursor,
506
+ // opencode) refuse `--scope user` instead of silently writing the project
507
+ // file. `config.path` is null only for providers with no config file at all
508
+ // (Warp, UI-configured), which never reach a destination check.
509
+ const projectBase = target === 'opencode' && projectDir === 'global' ? process.cwd() : projectDir;
510
+ const { userScope, projectRoot } = config.path
511
+ ? classifyMcpDestination(target, scope, config.path, projectBase)
512
+ : { userScope: Boolean(config.userScope), projectRoot: undefined };
513
+ if (config.path) await assertConfigDestination(config.path, projectRoot);
514
+
361
515
  // Handle custom handler (for TOML configs like Codex, or OpenCode JSON)
362
516
  if (config.handler) {
363
517
  return await config.handler(config.path, config.toml, config.content, config.merge);
364
518
  }
365
519
 
366
- // Ensure directory exists
367
- await fs.mkdir(path.dirname(config.path), { recursive: true });
368
-
369
520
  // Check if file exists and merge
370
521
  let existing = {};
371
522
  try {
372
523
  const content = await fs.readFile(config.path, 'utf-8');
373
524
  existing = JSON.parse(content);
374
- } catch {
375
- // File doesn't exist, start fresh
525
+ } catch (error) {
526
+ if (error instanceof SyntaxError) throw new Error(`Refusing to overwrite malformed MCP config ${config.path}: invalid JSON`);
527
+ if (error.code !== 'ENOENT') throw error;
528
+ }
529
+
530
+ const installServerMapKey = Object.keys(config.content)[0];
531
+ assertConfigObject(existing, installServerMapKey);
532
+
533
+ // Install-generated server definitions go through the same resolved
534
+ // credential policy as `aiwg mcp inject`, before any write. AIWG_ROOT (the
535
+ // only literal env value any install target's fixed content can ever
536
+ // carry, and only on the built-in "aiwg" entry) is a named, narrow
537
+ // exemption - installPolicyCheckedServer/INSTALL_POLICY_EXEMPT_ENV - not
538
+ // an unexamined bypass: any other literal env/header is still refused.
539
+ const installRegistry = new McpServerRegistry();
540
+ assertCredentialPolicy(
541
+ Object.entries(config.content[installServerMapKey] || {})
542
+ .map(([name, server]) => installPolicyCheckedServer(name, server)),
543
+ resolveCredentialPolicy({
544
+ registryPolicy: await installRegistry.getCredentialPolicy(),
545
+ floorPolicy: await installRegistry.getCredentialPolicyFloor(),
546
+ floorSource: await installRegistry.getCredentialPolicyFloorSource(),
547
+ }),
548
+ );
549
+
550
+ // Only the claude target's install content can ever embed a literal credential
551
+ // (`AIWG_ROOT`, gated to `scope === 'user'` above, and exempted above); every
552
+ // other target's `configs[...]` entry above is a fixed `aiwg mcp serve`
553
+ // stdio/TOML stanza with no env/headers field, so there is nothing further
554
+ // for assertProjectCredentials to check there.
555
+ if (target === 'claude' && scope !== 'user') {
556
+ assertProjectCredentials(Object.entries(config.content.mcpServers).map(([name, server]) => ({ name, ...server })), config.path);
376
557
  }
377
558
 
378
559
  // Merge configuration using custom merge function if available
@@ -387,7 +568,7 @@ enabled_tools = [
387
568
  }
388
569
  };
389
570
 
390
- await fs.writeFile(config.path, JSON.stringify(merged, null, 2));
571
+ await writeConfigAtomic(config.path, JSON.stringify(merged, null, 2), { userScope, projectRoot });
391
572
  console.log(`MCP configuration written to: ${config.path}`);
392
573
  console.log(`\nTo use AIWG MCP server with ${target}:`);
393
574
  console.log(` 1. Restart ${target}`);
@@ -477,6 +658,16 @@ function parseFlag(args, flag) {
477
658
  return args[idx + 1];
478
659
  }
479
660
 
661
+ /** Skip option values when locating a server name, regardless of flag order. */
662
+ function serverName(args) {
663
+ const valueFlags = new Set(['--type', '--url', '--command', '--args', '--env',
664
+ '--headers', '--header-env', '--env-from', '--description']);
665
+ for (let i = 0; i < args.length; i++) {
666
+ if (valueFlags.has(args[i])) { i++; continue; }
667
+ if (!args[i].startsWith('--')) return args[i];
668
+ }
669
+ }
670
+
480
671
  /**
481
672
  * Parse comma-separated key=value pairs into an object
482
673
  */
@@ -495,8 +686,7 @@ function parseKVPairs(str) {
495
686
  * Handle `aiwg mcp add <name> [opts]`
496
687
  */
497
688
  async function handleAdd(args) {
498
- const positional = args.filter(a => !a.startsWith('--'));
499
- const name = positional[0];
689
+ const name = serverName(args);
500
690
 
501
691
  if (!name) {
502
692
  console.error('Usage: aiwg mcp add <name> --url <url> [--type http|stdio|sse] [--command <cmd>] [--args <a,b>]');
@@ -510,6 +700,7 @@ async function handleAdd(args) {
510
700
  const envStr = parseFlag(args, '--env');
511
701
  const headersStr = parseFlag(args, '--headers');
512
702
  const headerEnvStr = parseFlag(args, '--header-env');
703
+ const envFromStr = parseFlag(args, '--env-from');
513
704
  const description = parseFlag(args, '--description');
514
705
 
515
706
  if (type === 'stdio' && !command) {
@@ -531,13 +722,17 @@ async function handleAdd(args) {
531
722
  env: parseKVPairs(envStr),
532
723
  headers: parseKVPairs(headersStr),
533
724
  headerEnv: parseKVPairs(headerEnvStr),
725
+ envFrom: parseKVPairs(envFromStr),
534
726
  description,
535
727
  });
536
728
 
537
729
  console.log(`Added MCP server: ${name}`);
538
- if (url) console.log(` URL: ${url}`);
539
- if (command) console.log(` Command: ${command}`);
730
+ if (url) console.log(` URL: ${redactUrlUserinfo(url)}`);
731
+ if (command) console.log(` Command: ${redactCredentialText(command)}`);
540
732
  console.log(` Type: ${type}`);
733
+ for (const [key, values] of [['env', parseKVPairs(envStr)], ['headers', parseKVPairs(headersStr)]]) {
734
+ if (values) console.log(` ${key}: ${Object.keys(values).join(', ')}`);
735
+ }
541
736
  console.log(`\nUse "aiwg mcp inject --provider <name>" to inject into a provider config.`);
542
737
  }
543
738
 
@@ -562,8 +757,7 @@ async function handleRemove(args) {
562
757
  * Handle `aiwg mcp update <name> [opts]`
563
758
  */
564
759
  async function handleUpdate(args) {
565
- const positional = args.filter(a => !a.startsWith('--'));
566
- const name = positional[0];
760
+ const name = serverName(args);
567
761
 
568
762
  if (!name) {
569
763
  console.error('Usage: aiwg mcp update <name> --url <url> [--type <type>] ...');
@@ -578,6 +772,7 @@ async function handleUpdate(args) {
578
772
  const envStr = parseFlag(args, '--env');
579
773
  const headersStr = parseFlag(args, '--headers');
580
774
  const headerEnvStr = parseFlag(args, '--header-env');
775
+ const envFromStr = parseFlag(args, '--env-from');
581
776
  const description = parseFlag(args, '--description');
582
777
 
583
778
  if (url !== undefined) updates.url = url;
@@ -587,6 +782,7 @@ async function handleUpdate(args) {
587
782
  if (envStr !== undefined) updates.env = parseKVPairs(envStr);
588
783
  if (headersStr !== undefined) updates.headers = parseKVPairs(headersStr);
589
784
  if (headerEnvStr !== undefined) updates.headerEnv = parseKVPairs(headerEnvStr);
785
+ if (envFromStr !== undefined) updates.envFrom = parseKVPairs(envFromStr);
590
786
  if (description !== undefined) updates.description = description;
591
787
 
592
788
  if (Object.keys(updates).length === 0) {
@@ -598,17 +794,24 @@ async function handleUpdate(args) {
598
794
  await registry.update(name, updates);
599
795
  console.log(`Updated MCP server: ${name}`);
600
796
  for (const [key, value] of Object.entries(updates)) {
601
- console.log(` ${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`);
797
+ const display = key === 'url' ? redactUrlUserinfo(value)
798
+ : key === 'args' ? JSON.stringify(redactCredentialArgs(value))
799
+ : ['env', 'headers', 'headerEnv', 'envFrom'].includes(key) ? Object.keys(value || {}).join(', ')
800
+ : typeof value === 'object' ? JSON.stringify(redactCredentialRecord(value)) : redactCredentialText(value);
801
+ console.log(` ${key}: ${display}`);
602
802
  }
603
803
  console.log(`\nRe-run "aiwg mcp inject --all" to propagate changes to provider configs.`);
604
804
  }
605
805
 
806
+ // Refusal and all display paths share the scanner in credentials.mjs.
807
+ const redactUrlUserinfo = redactCredentialText;
808
+
606
809
  /**
607
810
  * Handle `aiwg mcp list` (managed servers from registry)
608
811
  */
609
812
  async function handleList() {
610
813
  const registry = new McpServerRegistry();
611
- const servers = await registry.list();
814
+ const servers = redactCredentialRecord(await registry.list());
612
815
 
613
816
  if (servers.length === 0) {
614
817
  console.log('No MCP servers registered.');
@@ -621,13 +824,17 @@ async function handleList() {
621
824
  for (const server of servers) {
622
825
  console.log(` ${server.name}`);
623
826
  console.log(` Type: ${server.type}`);
624
- if (server.url) console.log(` URL: ${server.url}`);
625
- if (server.command) console.log(` Command: ${server.command}${server.args ? ' ' + server.args.join(' ') : ''}`);
827
+ if (server.url) console.log(` URL: ${redactUrlUserinfo(server.url)}`);
828
+ if (server.command) console.log(` Command: ${server.command}${server.args ? ' ' + redactCredentialArgs(server.args).join(' ') : ''}`);
626
829
  if (server.headerEnv) {
627
830
  const refs = Object.entries(server.headerEnv).map(([header, envName]) => `${header}←${envName}`);
628
831
  console.log(` Credential refs: ${refs.join(', ')}`);
629
832
  }
630
- if (server.description) console.log(` Description: ${server.description}`);
833
+ if (server.envFrom) {
834
+ const refs = Object.entries(server.envFrom).map(([key, envName]) => `${key}←${envName}`);
835
+ console.log(` Env refs: ${refs.join(', ')}`);
836
+ }
837
+ if (server.description) console.log(` Description: ${redactCredentialText(server.description)}`);
631
838
  if (server.injectedProviders && server.injectedProviders.length > 0) {
632
839
  console.log(` Injected into: ${server.injectedProviders.join(', ')}`);
633
840
  }
@@ -660,6 +867,12 @@ async function handleInject(args) {
660
867
  const profileName = parseFlag(args, '--profile');
661
868
  const ephemeral = args.includes('--ephemeral');
662
869
  const outPath = parseFlag(args, '--out');
870
+ const strictCredentials = args.includes('--strict-credentials');
871
+ const noCredentials = args.includes('--no-credentials');
872
+ if (strictCredentials && noCredentials) {
873
+ console.error('Error: --strict-credentials and --no-credentials are mutually exclusive.');
874
+ process.exit(1);
875
+ }
663
876
 
664
877
  if (!provider && !injectAll) {
665
878
  console.error('Usage: aiwg mcp inject --provider <name> [--profile <p>] [--ephemeral] [--servers a,b] [--dry-run]');
@@ -669,12 +882,19 @@ async function handleInject(args) {
669
882
  }
670
883
 
671
884
  const registry = new McpServerRegistry();
885
+ const credentialPolicy = resolveCredentialPolicy({
886
+ flag: noCredentials ? 'none' : strictCredentials ? 'references' : undefined,
887
+ registryPolicy: await registry.getCredentialPolicy(),
888
+ floorPolicy: await registry.getCredentialPolicyFloor(),
889
+ floorSource: await registry.getCredentialPolicyFloorSource(),
890
+ });
672
891
 
673
892
  // Resolve server filter: --profile takes precedence over --servers
674
893
  let serverFilter;
894
+ let profile;
675
895
  if (profileName) {
676
896
  const profiles = new McpProfileRegistry();
677
- const profile = await profiles.get(profileName);
897
+ profile = await profiles.resolve(profileName);
678
898
  if (!profile) {
679
899
  const all = await profiles.list();
680
900
  console.error(`Profile "${profileName}" not found.`);
@@ -707,6 +927,20 @@ async function handleInject(args) {
707
927
  providers = [normalized];
708
928
  }
709
929
 
930
+ if (injectAll && !ephemeral) {
931
+ const selected = (await registry.list()).filter(server => !serverFilter || serverFilter.includes(server.name));
932
+ for (const p of providers) {
933
+ const configPath = getProviderConfigPath(p, projectDir, { scope });
934
+ const { projectRoot } = classifyMcpDestination(p, scope, configPath, projectDir);
935
+ await assertConfigDestination(configPath, projectRoot);
936
+ // Every destination inside the project directory is meant to be committed
937
+ // to version control; home destinations are private.
938
+ if (projectRoot) {
939
+ assertProjectCredentials(selected, configPath, projectCredentialRemedy(p, projectDir));
940
+ }
941
+ }
942
+ }
943
+
710
944
  if (ephemeral) {
711
945
  for (const p of providers) {
712
946
  const mcpDefinition = getMcpInjectionDefinition(p);
@@ -723,23 +957,27 @@ async function handleInject(args) {
723
957
 
724
958
  let totalInjected = 0;
725
959
 
960
+ const printWarnings = (warnings = []) => {
961
+ for (const warning of warnings) console.error(` WARNING ${warning}`);
962
+ };
963
+
726
964
  for (const p of providers) {
965
+ const toolFilters = profile ? resolveToolFilters(profile, p) : undefined;
727
966
  if (ephemeral) {
728
967
  // Generate a standalone ephemeral config file
729
- const targetPath = outPath ?? path.join(
730
- process.env.TMPDIR || '/tmp',
731
- `aiwg-mcp-${profileName ?? 'custom'}-${p}-${Date.now()}.json`,
732
- );
733
-
734
968
  const allServers = await registry.list();
735
969
  const servers = serverFilter
736
970
  ? allServers.filter(s => serverFilter.includes(s.name))
737
971
  : allServers;
972
+ const toolPlan = hasToolFilters(toolFilters)
973
+ ? planToolFilters(p, servers.map(server => server.name), toolFilters)
974
+ : null;
738
975
 
739
976
  if (servers.length === 0) {
740
977
  console.error(` ${p}: no servers to write`);
741
978
  continue;
742
979
  }
980
+ assertCredentialPolicy(servers, credentialPolicy);
743
981
 
744
982
  // Build ephemeral config in provider's format
745
983
  const mcpDefinition = getMcpInjectionDefinition(p);
@@ -752,32 +990,59 @@ async function handleInject(args) {
752
990
  console.log(` Use "aiwg session --provider codex --profile ${profileName}" instead.`);
753
991
  continue;
754
992
  }
755
- const cfg = {};
756
- if (server.type === 'stdio') {
757
- cfg.command = server.command;
758
- cfg.args = server.args || [];
759
- if (server.env) cfg.env = server.env;
760
- } else {
761
- cfg.url = server.url;
762
- if (server.headers) cfg.headers = server.headers;
763
- }
764
- mcpBlock[server.name] = cfg;
993
+ mcpBlock[server.name] = buildServerConfig(server, p);
765
994
  }
766
995
 
767
996
  if (Object.keys(mcpBlock).length === 0) continue;
768
997
 
769
998
  const config = { [mcpKey]: mcpBlock };
999
+ if (toolPlan) applyJsonToolFilterPlan(config, mcpKey, toolPlan);
1000
+ const tempDir = outPath || dryRun
1001
+ ? undefined
1002
+ : await fs.mkdtemp(path.join(os.tmpdir(), 'aiwg-mcp-'));
1003
+ const targetPath = outPath ?? path.join(
1004
+ tempDir ?? path.join(os.tmpdir(), 'aiwg-mcp-<random>'),
1005
+ `${profileName ?? 'custom'}-${p}.json`,
1006
+ );
1007
+
1008
+ // Claude Code reads permission rules from settings, not from --mcp-config.
1009
+ const settingsPath = toolPlan?.claudePermissions ? targetPath.replace(/(\.json)?$/, '.settings.json') : null;
1010
+ // Explicit --out paths may be outside the project. Check every parent from
1011
+ // the filesystem root so neither destination can traverse a symlink.
1012
+ const outputRoot = path.parse(path.resolve(targetPath)).root;
1013
+ await assertConfigDestination(targetPath, outputRoot);
1014
+ const realProject = await fs.realpath(path.resolve(projectDir)).catch(error => {
1015
+ if (error.code !== 'ENOENT') throw error;
1016
+ return path.resolve(projectDir);
1017
+ });
1018
+ if (isPathInsideDir(targetPath, projectDir) || isPathInsideDir(targetPath, realProject)) {
1019
+ assertProjectCredentials(servers, targetPath, projectCredentialRemedy(p, projectDir));
1020
+ }
1021
+ const preparedPermissions = settingsPath
1022
+ ? await prepareClaudePermissions(settingsPath, toolPlan.claudePermissions, {
1023
+ projectRoot: outputRoot,
1024
+ userScope: true,
1025
+ managedDir: path.dirname(registry.getPath()),
1026
+ sidecar: true,
1027
+ mcpPath: targetPath,
1028
+ })
1029
+ : null;
770
1030
 
771
1031
  if (!dryRun) {
772
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
773
- await fs.writeFile(targetPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
1032
+ await writeConfigTransaction([
1033
+ { file: targetPath, content: JSON.stringify(config, null, 2) + '\n',
1034
+ options: { userScope: true, projectRoot: outputRoot } },
1035
+ ...(preparedPermissions?.writes || []),
1036
+ ]);
774
1037
  }
775
1038
 
776
1039
  const prefix = dryRun ? '[DRY RUN] ' : '';
777
1040
  console.log(`${prefix}${p}: ${targetPath}`);
778
1041
  console.log(` ${prefix}Servers: ${Object.keys(mcpBlock).join(', ')}`);
1042
+ if (settingsPath) console.log(` ${prefix}Tool permissions: ${settingsPath}`);
1043
+ printWarnings([...(toolPlan?.warnings || []), ...(preparedPermissions?.warnings || [])]);
779
1044
  if (!dryRun && (p === 'claude-code' || p === 'claude')) {
780
- console.log(` Launch with: claude --mcp-config ${targetPath}`);
1045
+ console.log(` Launch with: claude --mcp-config ${targetPath}${settingsPath ? ` --settings ${settingsPath}` : ''}`);
781
1046
  }
782
1047
  totalInjected += Object.keys(mcpBlock).length;
783
1048
  continue;
@@ -789,6 +1054,8 @@ async function handleInject(args) {
789
1054
  projectDir,
790
1055
  dryRun,
791
1056
  scope,
1057
+ ...(hasToolFilters(toolFilters) ? { toolFilters } : {}),
1058
+ credentialPolicy,
792
1059
  });
793
1060
 
794
1061
  if (result.error) {
@@ -806,6 +1073,8 @@ async function handleInject(args) {
806
1073
  if (result.alreadyPresent.length > 0) {
807
1074
  console.log(` ${prefix}Updated in place: ${result.alreadyPresent.join(', ')}`);
808
1075
  }
1076
+ if (result.settingsPath) console.log(` ${prefix}Tool permissions: ${result.settingsPath}`);
1077
+ printWarnings(result.warnings);
809
1078
  }
810
1079
 
811
1080
  if (!dryRun && totalInjected > 0 && !ephemeral) {
@@ -825,14 +1094,18 @@ function printProfileUsage() {
825
1094
  aiwg mcp profile — MCP server profiles (named server subsets)
826
1095
 
827
1096
  Usage:
828
- aiwg mcp profile add <name> --servers a,b,c [--description "..."]
1097
+ aiwg mcp profile add <name> --servers a,b,c [--extends base,...] [--description "..."]
1098
+ [--provider <p|*>] [--tool-deny s__t,...] [--tool-allow s__t,...]
829
1099
  aiwg mcp profile list
830
- aiwg mcp profile show <name>
1100
+ aiwg mcp profile show <name> [--json]
831
1101
  aiwg mcp profile edit <name> [--add-server x] [--remove-server y] [--description "..."]
1102
+ [--provider <p|*>] [--tool-deny s__t,...] [--tool-allow s__t,...]
1103
+ [--clear-tool-filters]
832
1104
  aiwg mcp profile remove <name>
833
1105
  aiwg mcp profile import <file>
834
1106
  aiwg mcp profile export <name> [--out <file>]
835
1107
  aiwg mcp profile init-presets
1108
+ aiwg mcp profile login <name> [--api-key-stdin | --api-key-file <path> | --api-key-env <ENV_VAR_NAME>]
836
1109
 
837
1110
  Profiles let you define named subsets of your registered MCP servers:
838
1111
  aiwg mcp profile add dev --servers git-gitea,memory-fortemi --description "Dev work"
@@ -840,11 +1113,60 @@ Profiles let you define named subsets of your registered MCP servers:
840
1113
  aiwg mcp inject --provider claude --profile dev --ephemeral
841
1114
  aiwg session --provider claude --profile dev
842
1115
 
1116
+ Layered configuration (organisation base, identity overlay):
1117
+ AIWG_CONFIG_LAYERS=/etc/aiwg/org:/home/me/.aiwg/acme aiwg mcp inject \
1118
+ --provider claude --profile acme-dev --ephemeral --out /tmp/acme.json
1119
+ Directories are listed lowest precedence first (path delimiter separated).
1120
+ A server or profile in a later layer replaces one of the same name in an
1121
+ earlier layer. Writes go to the last layer. --extends lets a profile inherit
1122
+ another profile's servers and tool filters from any layer.
1123
+
1124
+ Tool filters name tools as <server>__<tool>; <tool> may contain *. --provider
1125
+ defaults to *, which applies to every provider. inject renders them into each
1126
+ provider's own setting and warns about any filter the provider cannot express:
1127
+ aiwg mcp profile edit dev --tool-deny git-gitea__delete_repo
1128
+ aiwg mcp profile edit dev --provider codex --tool-allow git-gitea__list_repos
1129
+
843
1130
  Preset profiles (minimal, dev, ops, research, incident, full):
844
1131
  aiwg mcp profile init-presets
1132
+
1133
+ Logging in to a Codex profile's isolated runtime home (#3062):
1134
+ aiwg mcp profile login ops interactive (requires a terminal)
1135
+ vault read -field=key secret/openai |
1136
+ aiwg mcp profile login ops --api-key-stdin headless, most preferred — key never
1137
+ touches an environment variable or argv
1138
+ aiwg mcp profile login ops --api-key-file ~/.openai-key
1139
+ headless — file must be owned by you,
1140
+ mode 600, not a symlink
1141
+ aiwg mcp profile login ops --api-key-env OPENAI_KEY
1142
+ headless, least preferred — reads
1143
+ $OPENAI_KEY; visible to every other
1144
+ child process this shell starts
1145
+ In every case the key reaches codex over stdin only; it never appears on
1146
+ argv, in logs, or in error messages. Exactly one of the three may be
1147
+ given. Without a terminal and without any of the three, this errors
1148
+ naming all three. See "Supplying credentials safely" in the docs:
1149
+ docs/mcp/codex-profiles.md#supplying-credentials-safely
845
1150
  `);
846
1151
  }
847
1152
 
1153
+ /**
1154
+ * Parse --provider / --tool-deny / --tool-allow into a providerOverrides map.
1155
+ */
1156
+ function parseToolFilterFlags(args) {
1157
+ const deny = parseFlag(args, '--tool-deny');
1158
+ const allow = parseFlag(args, '--tool-allow');
1159
+ if (!deny && !allow) return undefined;
1160
+ const split = value => value.split(',').map(s => s.trim()).filter(Boolean);
1161
+ const provider = parseFlag(args, '--provider') || '*';
1162
+ return {
1163
+ [provider]: {
1164
+ ...(deny ? { toolDeny: split(deny) } : {}),
1165
+ ...(allow ? { toolAllow: split(allow) } : {}),
1166
+ },
1167
+ };
1168
+ }
1169
+
848
1170
  /**
849
1171
  * Handle `aiwg mcp profile add <name> [opts]`
850
1172
  */
@@ -858,17 +1180,26 @@ async function handleProfileAdd(args) {
858
1180
 
859
1181
  const serversStr = parseFlag(args, '--servers');
860
1182
  const description = parseFlag(args, '--description');
1183
+ const extendsStr = parseFlag(args, '--extends');
1184
+ const bases = extendsStr ? extendsStr.split(',').map(s => s.trim()).filter(Boolean) : undefined;
861
1185
 
862
- if (!serversStr && name !== 'minimal') {
1186
+ if (!serversStr && !bases && name !== 'minimal') {
863
1187
  console.error('Warning: no --servers specified. Profile will start empty.');
864
1188
  }
865
1189
 
866
1190
  const servers = serversStr ? serversStr.split(',').map(s => s.trim()).filter(Boolean) : [];
1191
+ const providerOverrides = parseToolFilterFlags(args);
867
1192
 
868
1193
  const profiles = new McpProfileRegistry();
869
1194
  const registry = new McpServerRegistry();
870
1195
 
871
- await profiles.add({ name, description, servers }, registry);
1196
+ await profiles.add({
1197
+ name,
1198
+ description,
1199
+ servers,
1200
+ ...(bases ? { extends: bases } : {}),
1201
+ ...(providerOverrides ? { providerOverrides } : {}),
1202
+ }, registry);
872
1203
 
873
1204
  console.log(`Profile added: ${name}`);
874
1205
  if (description) console.log(` Description: ${description}`);
@@ -881,7 +1212,7 @@ async function handleProfileAdd(args) {
881
1212
  */
882
1213
  async function handleProfileList() {
883
1214
  const profiles = new McpProfileRegistry();
884
- const all = await profiles.list();
1215
+ const all = redactCredentialRecord(await profiles.list());
885
1216
 
886
1217
  if (all.length === 0) {
887
1218
  console.log('No profiles defined.');
@@ -911,7 +1242,7 @@ async function handleProfileShow(args) {
911
1242
  }
912
1243
 
913
1244
  const profiles = new McpProfileRegistry();
914
- const profile = await profiles.get(name);
1245
+ const profile = redactCredentialRecord(await profiles.get(name));
915
1246
 
916
1247
  if (!profile) {
917
1248
  const all = await profiles.list();
@@ -922,8 +1253,18 @@ async function handleProfileShow(args) {
922
1253
  process.exit(1);
923
1254
  }
924
1255
 
1256
+ if (args.includes('--json')) {
1257
+ console.log(JSON.stringify(redactCredentialRecord(profile), null, 2));
1258
+ return;
1259
+ }
1260
+
925
1261
  console.log(`Profile: ${profile.name}`);
926
1262
  if (profile.description) console.log(`Description: ${profile.description}`);
1263
+ if (profile.extends?.length) {
1264
+ console.log(`Extends: ${profile.extends.join(', ')}`);
1265
+ const resolved = await profiles.resolve(name);
1266
+ console.log(`Resolved servers: ${resolved.servers.join(', ') || '(none)'}`);
1267
+ }
927
1268
  console.log(`\nServers (${profile.servers.length}):`);
928
1269
 
929
1270
  if (profile.servers.length === 0) {
@@ -934,11 +1275,11 @@ async function handleProfileShow(args) {
934
1275
  // Resolve server configs
935
1276
  const registry = new McpServerRegistry();
936
1277
  for (const serverName of profile.servers) {
937
- const server = await registry.get(serverName);
1278
+ const server = redactCredentialRecord(await registry.get(serverName));
938
1279
  if (server) {
939
1280
  const detail = server.type === 'stdio'
940
- ? `stdio ${server.command}${server.args ? ' ' + server.args.join(' ') : ''}`
941
- : `${server.type} ${server.url}`;
1281
+ ? `stdio ${server.command}${server.args ? ' ' + redactCredentialArgs(server.args).join(' ') : ''}`
1282
+ : `${server.type} ${redactUrlUserinfo(server.url)}`;
942
1283
  console.log(` ${serverName.padEnd(24)} ${detail}`);
943
1284
  if (server.description) console.log(` ${''.padEnd(24)} ${server.description}`);
944
1285
  } else {
@@ -975,14 +1316,18 @@ async function handleProfileEdit(args) {
975
1316
  const removeServer = parseFlag(args, '--remove-server');
976
1317
  const description = parseFlag(args, '--description');
977
1318
 
1319
+ const providerOverrides = parseToolFilterFlags(args);
1320
+ const clearToolFilters = args.includes('--clear-tool-filters');
978
1321
  const changes = {
979
1322
  description,
980
1323
  addServers: addServer ? addServer.split(',').map(s => s.trim()) : undefined,
981
1324
  removeServers: removeServer ? removeServer.split(',').map(s => s.trim()) : undefined,
1325
+ providerOverrides,
1326
+ clearToolFilters: clearToolFilters ? (parseFlag(args, '--provider') || '*') : undefined,
982
1327
  };
983
1328
 
984
- if (!description && !addServer && !removeServer) {
985
- console.error('No changes specified. Use --add-server, --remove-server, or --description.');
1329
+ if (!description && !addServer && !removeServer && !providerOverrides && !clearToolFilters) {
1330
+ console.error('No changes specified. Use --add-server, --remove-server, --description, --tool-deny, --tool-allow or --clear-tool-filters.');
986
1331
  process.exit(1);
987
1332
  }
988
1333
 
@@ -1055,6 +1400,135 @@ async function handleProfileInitPresets() {
1055
1400
  }
1056
1401
  }
1057
1402
 
1403
+ /**
1404
+ * Read all of stdin and return it as one trimmed string (go/no-go review,
1405
+ * blocker 1C's `--api-key-stdin`). Mirrors `src/cli/handlers/decision.ts`'s
1406
+ * own private `readStdin()` + `.trim()` for `--token-stdin` — duplicated
1407
+ * rather than imported for the same source/dist reason documented on
1408
+ * `requireFlagValue` above (this file runs both compiled and directly from
1409
+ * source). Input is expected to be a single line; a value containing
1410
+ * additional lines is not specially rejected, matching that precedent.
1411
+ */
1412
+ async function readStdinAll() {
1413
+ const chunks = [];
1414
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1415
+ return Buffer.concat(chunks).toString('utf8');
1416
+ }
1417
+
1418
+ /**
1419
+ * Handle `aiwg mcp profile login <name> [--api-key-env <ENV_VAR_NAME> |
1420
+ * --api-key-stdin | --api-key-file <path>]` (#3062, #3069, go/no-go review
1421
+ * blocker 1C).
1422
+ *
1423
+ * Wires `loginInProfile()` (src/mcp/adapters/codex-runtime.ts), which
1424
+ * already implements the full headless-safe behavior this command needs:
1425
+ * with a terminal attached and none of the three given, it runs the
1426
+ * interactive `codex login` OAuth flow. Exactly one credential source may
1427
+ * be given; more than one is a usage error naming the flags given. In
1428
+ * every case the key reaches `loginInProfile()` only as an in-memory value
1429
+ * (or, for `--api-key-env`, a variable *name*) — never on argv, in logs, or
1430
+ * in an error message — and travels to `codex` only on its stdin (see
1431
+ * `spawnCodexLoginWithApiKey()`'s own doc comment). Without a terminal and
1432
+ * without any of the three, `loginInProfile()` itself refuses, naming all
1433
+ * three flags.
1434
+ *
1435
+ * `--api-key-env` reads the key from that named environment variable, as
1436
+ * before. `--api-key-stdin` reads one line from this process's own stdin
1437
+ * (refusing if stdin is a terminal, or if it is empty) — the least
1438
+ * observable source, since the key then never exists in any environment or
1439
+ * on any command line. `--api-key-file <path>` reads the key from a file
1440
+ * that `readApiKeyFromFile()` requires to be a non-symlink, single-hard-
1441
+ * link, regular file owned by the current user with no group/other
1442
+ * permission bits on POSIX — refused otherwise, without ever printing the
1443
+ * path's contents. `--api-key-env` remains the least preferred of the
1444
+ * three (see "Supplying credentials safely" in the docs): the named
1445
+ * variable is visible to every other child process this shell starts.
1446
+ */
1447
+ async function handleProfileLogin(args) {
1448
+ let apiKeyEnv;
1449
+ let apiKeyFilePath;
1450
+ try {
1451
+ // rejectEmpty: an explicitly-given-but-empty variable name/path can
1452
+ // never name a real environment variable or file (#3062 second review,
1453
+ // finding 4; go/no-go review blocker 1C).
1454
+ apiKeyEnv = requireFlagValue(args, { rejectEmpty: true }, '--api-key-env');
1455
+ apiKeyFilePath = requireFlagValue(args, { rejectEmpty: true }, '--api-key-file');
1456
+ } catch (err) {
1457
+ if (err instanceof MissingFlagValueError) {
1458
+ console.error(`${err.message}.`);
1459
+ process.exit(2);
1460
+ }
1461
+ throw err;
1462
+ }
1463
+ const useStdin = args.includes('--api-key-stdin');
1464
+
1465
+ const sourcesGiven = [
1466
+ apiKeyEnv !== undefined ? '--api-key-env' : null,
1467
+ useStdin ? '--api-key-stdin' : null,
1468
+ apiKeyFilePath !== undefined ? '--api-key-file' : null,
1469
+ ].filter(Boolean);
1470
+ if (sourcesGiven.length > 1) {
1471
+ console.error(`Only one credential source may be given; got ${sourcesGiven.join(', ')}.`);
1472
+ process.exit(2);
1473
+ }
1474
+
1475
+ // Skip --api-key-env's/--api-key-file's own value when hunting for the
1476
+ // positional profile name, so e.g. `login --api-key-env FOO` (missing
1477
+ // name) never misreads FOO as the name (mirrors this file's own
1478
+ // `serverName()` helper). Stops at a bare `--` the same way
1479
+ // requireFlagValue() does.
1480
+ const positional = [];
1481
+ for (let i = 0; i < args.length; i += 1) {
1482
+ const token = args[i];
1483
+ if (token === '--') break;
1484
+ if (token === '--api-key-env' || token === '--api-key-file') { i += 1; continue; }
1485
+ if (!token.startsWith('--')) positional.push(token);
1486
+ }
1487
+ const name = positional[0];
1488
+ if (!name) {
1489
+ console.error('Usage: aiwg mcp profile login <name> [--api-key-env <ENV_VAR_NAME> | --api-key-stdin | --api-key-file <path>]');
1490
+ process.exit(1);
1491
+ }
1492
+
1493
+ let apiKey;
1494
+ if (useStdin) {
1495
+ if (process.stdin.isTTY) {
1496
+ console.error(
1497
+ '--api-key-stdin requires piped input, not a terminal; run ' +
1498
+ `\`aiwg mcp profile login ${name}\` with no credential flag for the interactive login instead.`,
1499
+ );
1500
+ process.exit(2);
1501
+ }
1502
+ apiKey = (await readStdinAll()).trim();
1503
+ if (!apiKey) {
1504
+ console.error('--api-key-stdin received no key (stdin was empty).');
1505
+ process.exit(2);
1506
+ }
1507
+ } else if (apiKeyFilePath !== undefined) {
1508
+ const { readApiKeyFromFile } = await loadCodexRuntimeAdapter();
1509
+ try {
1510
+ apiKey = await readApiKeyFromFile(apiKeyFilePath);
1511
+ } catch (err) {
1512
+ console.error(err instanceof Error ? err.message : String(err));
1513
+ process.exit(2);
1514
+ }
1515
+ if (!apiKey) {
1516
+ console.error(`--api-key-file "${apiKeyFilePath}" is empty.`);
1517
+ process.exit(2);
1518
+ }
1519
+ }
1520
+
1521
+ try {
1522
+ const { loginInProfile } = await loadCodexRuntimeAdapter();
1523
+ const loginOptions = apiKey !== undefined ? { apiKey } : apiKeyEnv ? { apiKeyEnv } : {};
1524
+ await loginInProfile(name, loginOptions);
1525
+ console.log(`\nLogged in to profile "${name}".`);
1526
+ } catch (err) {
1527
+ console.error(err instanceof Error ? err.message : String(err));
1528
+ process.exit(1);
1529
+ }
1530
+ }
1531
+
1058
1532
  /**
1059
1533
  * Route `aiwg mcp profile <subcommand>`
1060
1534
  */
@@ -1080,6 +1554,9 @@ async function handleProfile(args) {
1080
1554
  case 'rm':
1081
1555
  await handleProfileRemove(subArgs);
1082
1556
  break;
1557
+ case 'login':
1558
+ await handleProfileLogin(subArgs);
1559
+ break;
1083
1560
  case 'import':
1084
1561
  await handleProfileImport(subArgs);
1085
1562
  break;
@@ -1137,14 +1614,21 @@ export async function main(args = process.argv.slice(2)) {
1137
1614
  if (!['project', 'user'].includes(scope)) throw new Error('Scope must be project or user');
1138
1615
  const projectDir = parseFlag(args, '--project') || (args[2] && !args[2].startsWith('--') ? args[2] : '.');
1139
1616
  const configPath = getProviderConfigPath('omp', projectDir, { scope });
1140
- const result = await manageOmpMcp(configPath, [{ name: 'aiwg', type: 'stdio', command: 'aiwg', args: ['mcp', 'serve'] }], { dryRun: args.includes('--dry-run') });
1617
+ // Destination-derived, not scope-derived: a provider home-directory
1618
+ // override (e.g. PI_CODING_AGENT_DIR) that still resolves inside the
1619
+ // project must get project containment, whatever scope was requested.
1620
+ const { userScope, projectRoot } = classifyMcpDestination('omp', scope, configPath, projectDir);
1621
+ await assertConfigDestination(configPath, projectRoot);
1622
+ const result = await manageOmpMcp(configPath, [{ name: 'aiwg', type: 'stdio', command: 'aiwg', args: ['mcp', 'serve'] }], { dryRun: args.includes('--dry-run'), userScope, projectRoot });
1141
1623
  console.log(JSON.stringify(result, null, 2));
1142
1624
  break;
1143
1625
  }
1144
1626
  // Parse install arguments (skip flags)
1145
- const installArgs = args.slice(1).filter(a => !a.startsWith('--'));
1627
+ const scope = parseFlag(args, '--scope') || 'project';
1628
+ if (!['project', 'user'].includes(scope)) throw new Error('Scope must be project or user');
1629
+ const installArgs = args.slice(1).filter((a, index, rest) => !a.startsWith('--') && !['--scope', '--project'].includes(rest[index - 1]));
1146
1630
  const target = installArgs[0] || 'claude';
1147
- const projectDir = installArgs[1] || '.';
1631
+ const projectDir = parseFlag(args, '--project') || installArgs[1] || '.';
1148
1632
 
1149
1633
  // Check for --dry-run flag
1150
1634
  if (args.includes('--dry-run')) {
@@ -1152,13 +1636,13 @@ export async function main(args = process.argv.slice(2)) {
1152
1636
  console.log(`[DRY RUN] Would generate MCP config for: ${target}`);
1153
1637
  console.log(`[DRY RUN] Target directory: ${projectDir}`);
1154
1638
  const configPaths = {
1155
- claude: '.claude/settings.local.json',
1639
+ claude: scope === 'user' ? path.join(homeDir, '.claude.json') : path.join(projectDir, '.mcp.json'),
1156
1640
  cursor: '.cursor/mcp.json',
1157
1641
  factory: (projectDir === '.' || projectDir === 'global')
1158
1642
  ? path.join(homeDir, '.factory/mcp.json')
1159
1643
  : path.join(projectDir, '.factory/mcp.json'),
1160
- codex: path.join(homeDir, '.codex/config.toml'),
1161
- openai: path.join(homeDir, '.codex/config.toml'),
1644
+ codex: path.resolve(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'config.toml'),
1645
+ openai: path.resolve(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'config.toml'),
1162
1646
  vscode: '.vscode/mcp.json',
1163
1647
  copilot: '.vscode/mcp.json',
1164
1648
  opencode: (projectDir === '.' || projectDir === 'global')
@@ -1171,7 +1655,7 @@ export async function main(args = process.argv.slice(2)) {
1171
1655
  break;
1172
1656
  }
1173
1657
 
1174
- await generateConfig(target, projectDir);
1658
+ if (!await generateConfig(target, projectDir, scope)) process.exitCode = 1;
1175
1659
  break;
1176
1660
  }
1177
1661
 
@@ -1208,9 +1692,9 @@ export async function main(args = process.argv.slice(2)) {
1208
1692
  if (!['project', 'user'].includes(scope)) throw new Error('Scope must be project or user');
1209
1693
  const remove = (parseFlag(subArgs, '--servers') || '').split(',').map(s => s.trim()).filter(Boolean);
1210
1694
  if (!remove.length) throw new Error('uninject requires --servers name[,name]');
1211
- const configPath = getProviderConfigPath(provider, parseFlag(subArgs, '--project') || '.', { scope });
1695
+ const projectDir = parseFlag(subArgs, '--project') || '.';
1696
+ const configPath = getProviderConfigPath(provider, projectDir, { scope });
1212
1697
  if (provider === 'grok-build') {
1213
- const projectDir = parseFlag(subArgs, '--project') || '.';
1214
1698
  const result = await unmanageGrokBuildMcp(configPath, remove, {
1215
1699
  dryRun: subArgs.includes('--dry-run'),
1216
1700
  root: scope === 'user' ? path.dirname(path.dirname(configPath)) : path.resolve(projectDir),
@@ -1218,7 +1702,17 @@ export async function main(args = process.argv.slice(2)) {
1218
1702
  console.log(JSON.stringify(result, null, 2));
1219
1703
  break;
1220
1704
  }
1221
- const result = await manageOmpMcp(configPath, [], { remove, dryRun: subArgs.includes('--dry-run') });
1705
+ // Destination-derived, not scope-derived: a provider home-directory
1706
+ // override that still resolves inside the project must get project
1707
+ // containment, whatever scope was requested.
1708
+ const { userScope: ompUserScope, projectRoot: ompProjectRoot } =
1709
+ classifyMcpDestination(provider, scope, configPath, projectDir);
1710
+ const result = await manageOmpMcp(configPath, [], {
1711
+ remove,
1712
+ dryRun: subArgs.includes('--dry-run'),
1713
+ userScope: ompUserScope,
1714
+ projectRoot: ompProjectRoot,
1715
+ });
1222
1716
  console.log(JSON.stringify(result, null, 2));
1223
1717
  break;
1224
1718
  }
@@ -1227,6 +1721,22 @@ export async function main(args = process.argv.slice(2)) {
1227
1721
  await handleProfile(subArgs);
1228
1722
  break;
1229
1723
 
1724
+ case 'credential-policy': {
1725
+ const registry = new McpServerRegistry();
1726
+ const value = subArgs.find(a => !a.startsWith('--'));
1727
+ if (!value) {
1728
+ console.log(await registry.getCredentialPolicy() || 'literal');
1729
+ break;
1730
+ }
1731
+ if (!CREDENTIAL_POLICIES.includes(value)) {
1732
+ console.error(`Unknown credential policy "${value}". Use one of: ${CREDENTIAL_POLICIES.join(', ')}`);
1733
+ process.exit(1);
1734
+ }
1735
+ await registry.setCredentialPolicy(value);
1736
+ console.log(`Credential policy: ${value}`);
1737
+ break;
1738
+ }
1739
+
1230
1740
  case '--help':
1231
1741
  case '-h':
1232
1742
  case 'help':