agent-trellis 0.15.0 → 0.16.0

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.
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./command
24
24
  import { parseManageArgs, runManage } from "./commands/manage.js";
25
25
  import { runKimi } from "./commands/kimi.js";
26
26
  import { TRELLIS_VERSION } from "./lib/cliMetadata.js";
27
+ import { flagValue } from "./lib/cliArgs.js";
27
28
  import { parseSyncArgs } from "./lib/syncArgs.js";
28
29
  const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "mcp-runtime", "skill", "add", "update", "memory", "manage", "kimi", "secrets", "rollback"];
29
30
  function printUsage() {
@@ -193,6 +194,9 @@ Commands:
193
194
  Remove a canonical MCP server, then run the same sync as
194
195
  mcp sync so every managed agent's Trellis-owned entry for
195
196
  it is removed too
197
+ --workspace remove from .agents/config.json instead of
198
+ servers.yaml (a shadowed global entry takes
199
+ effect again, it is not deleted)
196
200
  --dry-run preview the plan, write nothing
197
201
  --json machine-readable output, no report text
198
202
  skill list
@@ -325,31 +329,15 @@ async function main(argv) {
325
329
  return;
326
330
  }
327
331
  if (command === "onboard") {
328
- const agentIndex = rest.indexOf("--agent");
329
- const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
330
- const manageIndex = rest.indexOf("--manage");
331
- const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
332
- const mcpModeIndex = rest.indexOf("--mcp-mode");
333
- const mcpMode = mcpModeIndex >= 0 ? rest[mcpModeIndex + 1] : undefined;
334
- const hubUrlIndex = rest.indexOf("--hub-url");
335
- const hubUrl = hubUrlIndex >= 0 ? rest[hubUrlIndex + 1] : undefined;
336
- const gatewayAgentsIndex = rest.indexOf("--gateway-agents");
337
- const gatewayAgents = gatewayAgentsIndex >= 0 ? rest[gatewayAgentsIndex + 1] : undefined;
338
- const memoryIndex = rest.indexOf("--memory");
339
- const memory = memoryIndex >= 0 ? rest[memoryIndex + 1] : undefined;
340
- const memoryMigrateIndex = rest.indexOf("--memory-migrate");
341
- const memoryMigrate = memoryMigrateIndex >= 0 ? rest[memoryMigrateIndex + 1] : undefined;
342
- const selectionIndex = rest.indexOf("--selection");
343
- const selectionFile = selectionIndex >= 0 ? rest[selectionIndex + 1] : undefined;
344
332
  const { exitCode } = await runOnboard({
345
- agent,
346
- manage,
347
- mcpMode,
348
- hubUrl,
349
- gatewayAgents,
350
- memory,
351
- memoryMigrate,
352
- selectionFile,
333
+ agent: flagValue(rest, "--agent"),
334
+ manage: flagValue(rest, "--manage"),
335
+ mcpMode: flagValue(rest, "--mcp-mode"),
336
+ hubUrl: flagValue(rest, "--hub-url"),
337
+ gatewayAgents: flagValue(rest, "--gateway-agents"),
338
+ memory: flagValue(rest, "--memory"),
339
+ memoryMigrate: flagValue(rest, "--memory-migrate"),
340
+ selectionFile: flagValue(rest, "--selection"),
353
341
  dryRun: rest.includes("--dry-run"),
354
342
  json: rest.includes("--json"),
355
343
  });
@@ -369,21 +357,16 @@ async function main(argv) {
369
357
  if (rest[i] === "--choose" && rest[i + 1] !== undefined)
370
358
  choose.push(rest[i + 1]);
371
359
  }
372
- const dedupIndex = rest.indexOf("--dedup");
373
360
  const { exitCode } = await runMigrateWorkspace({
374
361
  choose,
375
- dedup: dedupIndex >= 0 ? rest[dedupIndex + 1] : undefined,
362
+ dedup: flagValue(rest, "--dedup"),
376
363
  dryRun: rest.includes("--dry-run"),
377
364
  json: rest.includes("--json"),
378
365
  });
379
366
  process.exitCode = exitCode;
380
367
  return;
381
368
  }
382
- const fromIndex = rest.indexOf("--from");
383
- const from = fromIndex >= 0 ? rest[fromIndex + 1] : undefined;
384
- const onlyIndex = rest.indexOf("--only");
385
- const only = onlyIndex >= 0 ? rest[onlyIndex + 1] : undefined;
386
- const { exitCode } = await runMigrate({ from, only, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
369
+ const { exitCode } = await runMigrate({ from: flagValue(rest, "--from"), only: flagValue(rest, "--only"), dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
387
370
  process.exitCode = exitCode;
388
371
  return;
389
372
  }
@@ -511,7 +494,7 @@ async function main(argv) {
511
494
  process.exitCode = 1;
512
495
  return;
513
496
  }
514
- process.exitCode = (await runMcpRemove(name, { json, dryRun })).exitCode;
497
+ process.exitCode = (await runMcpRemove(name, { json, dryRun, workspace: mcpRest.includes("--workspace") })).exitCode;
515
498
  return;
516
499
  }
517
500
  if (subcommand === "renew") {
@@ -544,8 +527,7 @@ async function main(argv) {
544
527
  }
545
528
  if (subcommand === "add") {
546
529
  const [name] = skillRest;
547
- const fromIndex = skillRest.indexOf("--from");
548
- const from = fromIndex >= 0 ? skillRest[fromIndex + 1] : undefined;
530
+ const from = flagValue(skillRest, "--from");
549
531
  if (!name || !from) {
550
532
  console.error("Usage: trellis skill add <name> --from <path>");
551
533
  process.exitCode = 1;
@@ -15,7 +15,7 @@ import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
15
15
  * machine.
16
16
  */
17
17
  export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
18
- export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable";
18
+ export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid";
19
19
  export interface Finding {
20
20
  kind: FindingKind;
21
21
  /** Omitted for a finding that names more than one agent (drift). */
@@ -44,6 +44,10 @@ const EXIT_NONZERO_KINDS = new Set([
44
44
  // exists to catch *before* the session does (trellis-mcp-oauth-renew
45
45
  // design.md D4).
46
46
  "oauth-expired",
47
+ // A project-level Codex config that fails TOML parsing will fail the
48
+ // *agent*'s own startup regardless of ownership — catch it here before
49
+ // the next session does (trellis-doctor-workspace-toml-validation).
50
+ "workspace-toml-invalid",
47
51
  ]);
48
52
  /** Exported so tests can assert on this resolution directly, without
49
53
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -120,7 +124,53 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
120
124
  // canonical has not been initialized yet.
121
125
  }
122
126
  }
123
- const workspace = canonical?.workspace ? collectWorkspaceDoctorSection(canonical) : undefined;
127
+ // Workspace attribution runs whenever a workspace root is in scope —
128
+ // even when `~/.trellis/` itself failed to load or has never been
129
+ // initialized. The spec's third scenario: a user managing only project
130
+ // skills in `.agents/skills/` without a global Trellis install still
131
+ // gets project-level config validation
132
+ // (trellis-doctor-workspace-toml-validation design.md D5). When the
133
+ // canonical source is absent we synthesize the minimal workspace layer
134
+ // with every map empty — provenance is moot without a global layer, but
135
+ // `collectWorkspaceDoctorSection`'s per-file inspection is what we want.
136
+ let workspace;
137
+ if (canonical?.workspace) {
138
+ workspace = collectWorkspaceDoctorSection(canonical);
139
+ }
140
+ else {
141
+ const workspaceRoot = workspaceDir != null ? findWorkspaceRoot(workspaceDir) : null;
142
+ if (workspaceRoot) {
143
+ // `managedAgents` is driven by `~/.trellis/managed.yaml`, which is
144
+ // absent in this branch — sweep every known agent so codex's TOML
145
+ // file is still inspected (otherwise an empty list would skip the
146
+ // file entirely and the spec's scenario-3 finding would never fire).
147
+ const minimal = {
148
+ instructionsFile: "",
149
+ managedAgents: ALL_AGENTS,
150
+ skills: [],
151
+ agents: [],
152
+ memories: [],
153
+ mcp: { servers: {}, knownHostInjected: [] },
154
+ secretsPolicy: { allowedVars: [], rejectPatterns: [] },
155
+ diagnostics: [],
156
+ workspace: { root: workspaceRoot, mcpProvenance: {}, mcpGlobalServers: {}, skillProvenance: {}, globalSkills: [], shadowed: [] },
157
+ };
158
+ workspace = collectWorkspaceDoctorSection(minimal);
159
+ }
160
+ }
161
+ // Workspace TOML findings become top-level findings so the exit code
162
+ // and `--json` consumers see them alongside cross-agent findings, not
163
+ // only inside the workspace section. They keep `agent: "codex"` — the
164
+ // file layout tells us which agent the file breaks.
165
+ if (workspace) {
166
+ for (const finding of workspace.tomlFindings) {
167
+ findings.push({
168
+ kind: "workspace-toml-invalid",
169
+ agent: finding.agent,
170
+ message: `${finding.path}${finding.line !== undefined ? `:${finding.line}${finding.column !== undefined ? `:${finding.column}` : ""}` : ""} — ${finding.reason} (not rendered by Trellis; fix by hand)`,
171
+ });
172
+ }
173
+ }
124
174
  const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
125
175
  findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
126
176
  return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}), ...(workspace ? { workspace } : {}) };
@@ -395,6 +445,10 @@ export function printReport(report) {
395
445
  for (const hint of ws.unmigrated) {
396
446
  console.log(` ⚠ ${hint.path} holds ${hint.entries.length} entr${hint.entries.length === 1 ? "y" : "ies"} Trellis didn't render (${hint.entries.join(", ")}) — run \`trellis migrate --workspace\``);
397
447
  }
448
+ for (const finding of ws.tomlFindings) {
449
+ const location = finding.line !== undefined ? `:${finding.line}${finding.column !== undefined ? `:${finding.column}` : ""}` : "";
450
+ console.log(` ⚠ ${finding.path}${location} — ${finding.reason} (not rendered by Trellis — fix by hand and re-run \`trellis doctor\`)`);
451
+ }
398
452
  for (const warning of ws.precedenceWarnings) {
399
453
  console.log(` ⚠ ${warning}`);
400
454
  }
@@ -120,8 +120,9 @@ export declare function runMcpList(opts?: {
120
120
  };
121
121
  /** Raw, unvalidated CLI flag values — `collectMcpAddPlan` does the actual
122
122
  * parsing/validation. Kept as plain strings here so `parseMcpAddArgs`
123
- * stays a dumb `--flag value` reader, same posture as every other
124
- * command's own inline flag parsing in src/cli.ts. */
123
+ * stays a dumb `--flag value` reader. Both `--flag value` and
124
+ * `--flag=value` are accepted (see src/lib/cliArgs.ts — the `=` form used
125
+ * to be silently dropped). */
125
126
  export interface McpAddRawArgs {
126
127
  transport?: string;
127
128
  auth?: string;
@@ -139,6 +140,9 @@ export interface McpAddRawArgs {
139
140
  agents?: string;
140
141
  /** `"true"` or `"false"`. */
141
142
  enabled?: string;
143
+ /** `--*` tokens no flag claimed (typos, wrong command); `runMcpAdd`
144
+ * warns about them instead of letting them vanish silently. */
145
+ unknown?: string[];
142
146
  }
143
147
  export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
144
148
  export declare function parseMcpSetArgs(argv: readonly string[]): McpSetAuthRawArgs;
@@ -199,6 +203,8 @@ export interface McpSetAuthRawArgs {
199
203
  clientId?: string;
200
204
  /** A variable NAME, never a secret value. */
201
205
  clientSecretEnv?: string;
206
+ /** See `McpAddRawArgs.unknown`. */
207
+ unknown?: string[];
202
208
  }
203
209
  /**
204
210
  * Sets or clears the OAuth classification, and optionally the
@@ -342,10 +348,27 @@ export declare function applyMcpRemoveWithSync(plan: McpRemovePlan, opts?: {
342
348
  dryRun?: boolean;
343
349
  backupSession?: BackupSession;
344
350
  }): Promise<McpRemoveOutcome>;
351
+ /** Workspace twin of `collectMcpRemovePlan`: presence is judged against
352
+ * `.agents/config.json` only — a global entry of the same name is this
353
+ * entry's shadow target, not a reason to touch the global file. */
354
+ export declare function collectMcpRemoveWorkspacePlan(name: string, workspaceRoot: string): McpRemovePlan;
355
+ /** Workspace twin of `applyMcpRemoveWithSync`: deletes the entry from
356
+ * `.agents/config.json` (backup-session wrapped), then cascades the same
357
+ * mcp sync — run inside the workspace, so `planWorkspaceMcp` sees the
358
+ * entry is gone and removes the rendered project-level copies (the
359
+ * ledger-gated propagation already exists; this command was the missing
360
+ * corner). A shadowed global entry simply takes effect again. */
361
+ export declare function applyMcpRemoveWorkspaceWithSync(plan: McpRemovePlan, workspaceRoot: string, opts?: {
362
+ homeDir?: string;
363
+ dryRun?: boolean;
364
+ backupSession?: BackupSession;
365
+ }): Promise<McpRemoveOutcome>;
345
366
  export declare function runMcpRemove(name: string, opts?: {
346
367
  homeDir?: string;
347
368
  json?: boolean;
348
369
  dryRun?: boolean;
370
+ workspace?: boolean;
371
+ workspaceDir?: string;
349
372
  }): Promise<{
350
373
  exitCode: number;
351
374
  }>;
@@ -29,8 +29,9 @@ import { ZcodeAdapter } from "../adapters/zcode.js";
29
29
  import { openBackupSession } from "../lib/backup.js";
30
30
  import { findWorkspaceRoot } from "../lib/workspace.js";
31
31
  import { applyWorkspaceMcp, planWorkspaceMcp, workspacePrecedenceWarnings } from "../lib/workspaceMcp.js";
32
- import { readWorkspaceConfigJson, upsertWorkspaceServer } from "../lib/workspaceConfig.js";
32
+ import { readWorkspaceConfigJson, removeWorkspaceServer, upsertWorkspaceServer } from "../lib/workspaceConfig.js";
33
33
  import { toServerDefYaml } from "../core/mcpServerDef.js";
34
+ import { flagValue, unknownFlags } from "../lib/cliArgs.js";
34
35
  const ADAPTER_FACTORY = {
35
36
  "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
36
37
  codex: (homeDir) => new CodexAdapter(homeDir),
@@ -232,34 +233,39 @@ export function runMcpList(opts = {}) {
232
233
  }
233
234
  return { exitCode: 0 };
234
235
  }
236
+ const MCP_ADD_KNOWN_FLAGS = [
237
+ "--transport", "--auth", "--command", "--args", "--url", "--headers",
238
+ "--env", "--static-env", "--agents", "--enabled",
239
+ "--workspace", "--dry-run", "--json",
240
+ ];
235
241
  export function parseMcpAddArgs(argv) {
236
- const flag = (name) => {
237
- const i = argv.indexOf(name);
238
- return i >= 0 ? argv[i + 1] : undefined;
239
- };
242
+ const unknown = unknownFlags(argv, MCP_ADD_KNOWN_FLAGS);
240
243
  return {
241
- transport: flag("--transport"),
242
- auth: flag("--auth"),
243
- command: flag("--command"),
244
- args: flag("--args"),
245
- url: flag("--url"),
246
- headers: flag("--headers"),
247
- env: flag("--env"),
248
- staticEnv: flag("--static-env"),
249
- agents: flag("--agents"),
250
- enabled: flag("--enabled"),
244
+ transport: flagValue(argv, "--transport"),
245
+ auth: flagValue(argv, "--auth"),
246
+ command: flagValue(argv, "--command"),
247
+ args: flagValue(argv, "--args"),
248
+ url: flagValue(argv, "--url"),
249
+ headers: flagValue(argv, "--headers"),
250
+ env: flagValue(argv, "--env"),
251
+ staticEnv: flagValue(argv, "--static-env"),
252
+ agents: flagValue(argv, "--agents"),
253
+ enabled: flagValue(argv, "--enabled"),
254
+ ...(unknown.length > 0 ? { unknown } : {}),
251
255
  };
252
256
  }
257
+ const MCP_SET_KNOWN_FLAGS = [
258
+ "--auth", "--client-id", "--client-secret-env", "--auth-owner",
259
+ "--dry-run", "--json",
260
+ ];
253
261
  export function parseMcpSetArgs(argv) {
254
- const flag = (name) => {
255
- const i = argv.indexOf(name);
256
- return i >= 0 ? argv[i + 1] : undefined;
257
- };
262
+ const unknown = unknownFlags(argv, MCP_SET_KNOWN_FLAGS);
258
263
  return {
259
- auth: flag("--auth"),
260
- clientId: flag("--client-id"),
261
- clientSecretEnv: flag("--client-secret-env"),
262
- authOwner: flag("--auth-owner"),
264
+ auth: flagValue(argv, "--auth"),
265
+ clientId: flagValue(argv, "--client-id"),
266
+ clientSecretEnv: flagValue(argv, "--client-secret-env"),
267
+ authOwner: flagValue(argv, "--auth-owner"),
268
+ ...(unknown.length > 0 ? { unknown } : {}),
263
269
  };
264
270
  }
265
271
  function parseCsv(raw) {
@@ -491,6 +497,7 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
491
497
  }
492
498
  export function runMcpSetAuth(name, raw, opts = {}) {
493
499
  const homeDir = opts.homeDir ?? homedir();
500
+ warnUnknownFlags("mcp set", raw.unknown);
494
501
  let plan;
495
502
  try {
496
503
  plan = collectMcpSetAuthPlan(name, raw, homeDir);
@@ -671,8 +678,16 @@ export async function applyMcpAddWorkspaceWithSync(plan, workspaceRoot, opts = {
671
678
  }
672
679
  return { plan, ...(syncReport ? { sync: syncReport } : {}) };
673
680
  }
681
+ /** Warn about `--*` tokens the parser didn't claim — a typo like
682
+ * `--tranport http` must not exit 0 in silence. */
683
+ function warnUnknownFlags(commandLabel, unknown) {
684
+ if (unknown && unknown.length > 0) {
685
+ console.error(`warning: ${commandLabel}: unrecognized flag(s) ${unknown.map((f) => `"${f}"`).join(", ")} — ignored`);
686
+ }
687
+ }
674
688
  export async function runMcpAdd(name, raw, opts = {}) {
675
689
  const homeDir = opts.homeDir ?? homedir();
690
+ warnUnknownFlags("mcp add", raw.unknown);
676
691
  if (opts.workspace) {
677
692
  const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
678
693
  if (!workspaceRoot) {
@@ -767,8 +782,65 @@ export async function applyMcpRemoveWithSync(plan, opts = {}) {
767
782
  }
768
783
  return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
769
784
  }
785
+ /** Workspace twin of `collectMcpRemovePlan`: presence is judged against
786
+ * `.agents/config.json` only — a global entry of the same name is this
787
+ * entry's shadow target, not a reason to touch the global file. */
788
+ export function collectMcpRemoveWorkspacePlan(name, workspaceRoot) {
789
+ const config = readWorkspaceConfigJson(workspaceRoot);
790
+ return { name, action: config.mcp?.servers?.[name] ? "removed" : "not-found" };
791
+ }
792
+ /** Workspace twin of `applyMcpRemoveWithSync`: deletes the entry from
793
+ * `.agents/config.json` (backup-session wrapped), then cascades the same
794
+ * mcp sync — run inside the workspace, so `planWorkspaceMcp` sees the
795
+ * entry is gone and removes the rendered project-level copies (the
796
+ * ledger-gated propagation already exists; this command was the missing
797
+ * corner). A shadowed global entry simply takes effect again. */
798
+ export async function applyMcpRemoveWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
799
+ const homeDir = opts.homeDir ?? homedir();
800
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "removed" ? openBackupSession(homeDir, "mcp-remove") : undefined;
801
+ const session = opts.backupSession ?? ownSession;
802
+ if (!opts.dryRun && plan.action === "removed") {
803
+ removeWorkspaceServer(workspaceRoot, plan.name, session);
804
+ }
805
+ ownSession?.finalize();
806
+ const planFailed = plan.action === "not-found";
807
+ let syncReport;
808
+ if (!planFailed && existsSync(join(homeDir, ".trellis"))) {
809
+ syncReport = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
810
+ }
811
+ return { plan, ...(syncReport ? { sync: syncReport } : {}) };
812
+ }
770
813
  export async function runMcpRemove(name, opts = {}) {
771
814
  const homeDir = opts.homeDir ?? homedir();
815
+ if (opts.workspace) {
816
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
817
+ if (!workspaceRoot) {
818
+ console.error("no .agents/ found here or above — run `trellis init --workspace` first (or pass --workspace from inside the repo)");
819
+ return { exitCode: 1 };
820
+ }
821
+ let plan;
822
+ try {
823
+ plan = collectMcpRemoveWorkspacePlan(name, workspaceRoot);
824
+ }
825
+ catch (err) {
826
+ console.error(err instanceof Error ? err.message : String(err));
827
+ return { exitCode: 1 };
828
+ }
829
+ const { sync: syncReport } = await applyMcpRemoveWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
830
+ if (opts.json) {
831
+ console.log(JSON.stringify(syncReport ? { ...plan, workspace: workspaceRoot, sync: syncReport } : { ...plan, workspace: workspaceRoot }, null, 2));
832
+ }
833
+ else if (plan.action === "not-found") {
834
+ console.error(`"${name}" is not in the workspace layer (${workspaceRoot}/.agents/config.json) — nothing to remove.`);
835
+ }
836
+ else {
837
+ console.log(`${opts.dryRun ? "[dry run] " : ""}removed MCP server "${name}" from ${workspaceRoot}/.agents/config.json.`);
838
+ if (syncReport)
839
+ printReport(syncReport, opts.dryRun ?? false);
840
+ }
841
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
842
+ return { exitCode: plan.action === "not-found" || syncConflict ? 1 : 0 };
843
+ }
772
844
  let plan;
773
845
  try {
774
846
  plan = collectMcpRemovePlan(name, homeDir);
@@ -792,7 +864,22 @@ export async function runMcpRemove(name, opts = {}) {
792
864
  console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
793
865
  }
794
866
  else if (plan.action === "not-found") {
795
- console.error(`"${name}" is not a canonical MCP server — nothing to remove.`);
867
+ // A name that lives only in the workspace layer used to get this same
868
+ // blunt message (found by the v0.15.0 real-machine smoke test) —
869
+ // point at the flag instead of implying the entry doesn't exist.
870
+ let workspaceHint = "";
871
+ const workspaceRoot = opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd());
872
+ if (workspaceRoot) {
873
+ try {
874
+ if (readWorkspaceConfigJson(workspaceRoot).mcp?.servers?.[name]) {
875
+ workspaceHint = ` here, but it exists in the workspace layer — re-run with --workspace to remove it from ${workspaceRoot}/.agents/config.json`;
876
+ }
877
+ }
878
+ catch {
879
+ // An unreadable workspace config must not mask the primary answer.
880
+ }
881
+ }
882
+ console.error(`"${name}" is not a canonical MCP server${workspaceHint} — nothing to remove.`);
796
883
  }
797
884
  else if (writeError) {
798
885
  console.error(` write failed: ${writeError}`);
@@ -28,16 +28,16 @@ import { AgentBridgeProvider } from "../lib/agentBridgeProvider.js";
28
28
  import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
29
29
  import { SkillProvider } from "../lib/skillProvider.js";
30
30
  import { ALL_AGENTS, isOAuthAuth } from "../core/types.js";
31
+ import { flagValue } from "../lib/cliArgs.js";
31
32
  const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
32
33
  const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
33
34
  const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
34
35
  const RUNTIME_SERVER_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
35
36
  export function parseMcpGatewayArgs(argv) {
36
- const index = argv.indexOf("--agent");
37
- if (index === -1 || index === argv.length - 1) {
37
+ const value = flagValue(argv, "--agent");
38
+ if (value === undefined) {
38
39
  return { error: `mcp-gateway requires --agent <${ALL_AGENTS.join("|")}>` };
39
40
  }
40
- const value = argv[index + 1];
41
41
  if (!ALL_AGENTS.includes(value)) {
42
42
  return { error: `unknown agent "${value}" — expected one of ${ALL_AGENTS.join(", ")}` };
43
43
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared CLI flag readers. Every command historically rolled its own
3
+ * `argv.indexOf("--flag")` lookup — which silently ignores the equally
4
+ * common `--flag=value` form (real incident, found by the v0.15.0
5
+ * real-machine smoke test: `mcp add --args=-y,…` exited 0 with the args
6
+ * silently dropped from canonical). These helpers accept both forms, and
7
+ * `unknownFlags` catches typos instead of letting them vanish.
8
+ */
9
+ /** Value of `--name <value>` or `--name=<value>`; undefined when absent.
10
+ * The space form returns the next token verbatim (even one starting with
11
+ * `-`), matching the historical `indexOf` behavior. First occurrence wins,
12
+ * same as `indexOf`. */
13
+ export declare function flagValue(argv: readonly string[], name: string): string | undefined;
14
+ /** `--*` tokens none of `known` claims. Non-flag tokens (positionals,
15
+ * values) are never flags; a `--name=value` token is classified by its
16
+ * name part, so a known flag in `=` form is not "unknown". */
17
+ export declare function unknownFlags(argv: readonly string[], known: readonly string[]): string[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared CLI flag readers. Every command historically rolled its own
3
+ * `argv.indexOf("--flag")` lookup — which silently ignores the equally
4
+ * common `--flag=value` form (real incident, found by the v0.15.0
5
+ * real-machine smoke test: `mcp add --args=-y,…` exited 0 with the args
6
+ * silently dropped from canonical). These helpers accept both forms, and
7
+ * `unknownFlags` catches typos instead of letting them vanish.
8
+ */
9
+ /** Value of `--name <value>` or `--name=<value>`; undefined when absent.
10
+ * The space form returns the next token verbatim (even one starting with
11
+ * `-`), matching the historical `indexOf` behavior. First occurrence wins,
12
+ * same as `indexOf`. */
13
+ export function flagValue(argv, name) {
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const token = argv[i];
16
+ if (token === name)
17
+ return argv[i + 1];
18
+ if (token.startsWith(`${name}=`))
19
+ return token.slice(name.length + 1);
20
+ }
21
+ return undefined;
22
+ }
23
+ /** `--*` tokens none of `known` claims. Non-flag tokens (positionals,
24
+ * values) are never flags; a `--name=value` token is classified by its
25
+ * name part, so a known flag in `=` form is not "unknown". */
26
+ export function unknownFlags(argv, known) {
27
+ const result = [];
28
+ for (const token of argv) {
29
+ if (!token.startsWith("--"))
30
+ continue;
31
+ const key = token.split("=", 1)[0];
32
+ if (!known.includes(key))
33
+ result.push(token);
34
+ }
35
+ return result;
36
+ }
@@ -27,7 +27,7 @@ export type ScopeSelection = {
27
27
  detail: string;
28
28
  };
29
29
  export declare const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
30
- /** `argv.indexOf`-style reader, same idiom as every other command's flags. */
30
+ /** Shared reader — accepts `--agents <ids>` and `--agents=<ids>`. */
31
31
  export declare function parseScopeArgs(argv: readonly string[]): ScopeSelectorRaw;
32
32
  export declare function resolveScopeSelection(raw: ScopeSelectorRaw, managed: readonly AgentId[]): ScopeSelection;
33
33
  /** Order-insensitive equality between the scope on disk and the one about to
@@ -6,12 +6,13 @@
6
6
  * disagree about what a given selection means.
7
7
  */
8
8
  import { ALL_AGENTS } from "../core/types.js";
9
+ import { flagValue } from "./cliArgs.js";
9
10
  export const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
10
- /** `argv.indexOf`-style reader, same idiom as every other command's flags. */
11
+ /** Shared reader — accepts `--agents <ids>` and `--agents=<ids>`. */
11
12
  export function parseScopeArgs(argv) {
12
- const i = argv.indexOf("--agents");
13
+ const agents = flagValue(argv, "--agents");
13
14
  return {
14
- ...(i >= 0 ? { agents: argv[i + 1] ?? "" } : {}),
15
+ ...(agents !== undefined ? { agents } : {}),
15
16
  all: argv.includes("--all"),
16
17
  none: argv.includes("--none"),
17
18
  };
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Write-side helpers for the workspace layer (trellis-workspace-scope
3
3
  * tasks 3.3/3.4): scaffolding `.agents/` (`init --workspace`) and
4
- * upserting a server into `.agents/config.json` (`mcp add --workspace`).
4
+ * upserting/removing a server in `.agents/config.json`
5
+ * (`mcp add --workspace` / `mcp remove --workspace`).
5
6
  *
6
7
  * The read side lives in src/lib/workspace.ts (`loadWorkspaceLayer`) —
7
8
  * same file, same schema, one validator (via `toServerDefYaml`, so a
@@ -31,6 +32,11 @@ export declare function writeWorkspaceConfigJson(workspaceRoot: string, config:
31
32
  * the shared YAML/JSON translator. `version` is filled in when absent —
32
33
  * an existing file's version (and any unknown keys) is preserved. */
33
34
  export declare function upsertWorkspaceServer(workspaceRoot: string, name: string, def: McpServerDef, backup?: BackupSession): void;
35
+ /** Delete `mcp.servers.<name>`, preserving every other key (unknown keys
36
+ * included, same posture as the upsert). Returns false — and writes
37
+ * nothing — when the entry isn't there. The follow-up `mcp sync` is what
38
+ * propagates the deletion to the rendered project-level files. */
39
+ export declare function removeWorkspaceServer(workspaceRoot: string, name: string, backup?: BackupSession): boolean;
34
40
  export interface WorkspaceScaffoldFile {
35
41
  path: string;
36
42
  action: "create" | "already-present";
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Write-side helpers for the workspace layer (trellis-workspace-scope
3
3
  * tasks 3.3/3.4): scaffolding `.agents/` (`init --workspace`) and
4
- * upserting a server into `.agents/config.json` (`mcp add --workspace`).
4
+ * upserting/removing a server in `.agents/config.json`
5
+ * (`mcp add --workspace` / `mcp remove --workspace`).
5
6
  *
6
7
  * The read side lives in src/lib/workspace.ts (`loadWorkspaceLayer`) —
7
8
  * same file, same schema, one validator (via `toServerDefYaml`, so a
@@ -59,6 +60,24 @@ export function upsertWorkspaceServer(workspaceRoot, name, def, backup) {
59
60
  };
60
61
  writeWorkspaceConfigJson(workspaceRoot, next, backup);
61
62
  }
63
+ /** Delete `mcp.servers.<name>`, preserving every other key (unknown keys
64
+ * included, same posture as the upsert). Returns false — and writes
65
+ * nothing — when the entry isn't there. The follow-up `mcp sync` is what
66
+ * propagates the deletion to the rendered project-level files. */
67
+ export function removeWorkspaceServer(workspaceRoot, name, backup) {
68
+ const config = readWorkspaceConfigJson(workspaceRoot);
69
+ const servers = config.mcp?.servers;
70
+ if (!servers || !(name in servers))
71
+ return false;
72
+ const nextServers = { ...servers };
73
+ delete nextServers[name];
74
+ const next = {
75
+ ...config,
76
+ mcp: { ...(config.mcp ?? {}), servers: nextServers },
77
+ };
78
+ writeWorkspaceConfigJson(workspaceRoot, next, backup);
79
+ return true;
80
+ }
62
81
  /** Exported for `migrate --workspace`: a `.agents/AGENTS.md` holding
63
82
  * exactly this template is treated as still-empty scaffolding that an
64
83
  * instructions import may replace, not as user content to conflict on. */
@@ -20,6 +20,19 @@ export interface WorkspaceUnmigratedHint {
20
20
  /** Entry names present in the file with no Trellis manifest record. */
21
21
  entries: string[];
22
22
  }
23
+ /** One parse-level failure when inspecting an agent's project MCP file.
24
+ * Distinct from `parse-diagnostic` (agent-global probes). Even when Trellis
25
+ * has never rendered into this file, a project file that breaks the agent's
26
+ * own startup is exactly what doctor exists to surface
27
+ * (trellis-doctor-workspace-toml-validation). */
28
+ export interface WorkspaceTomlFinding {
29
+ agent: AgentId;
30
+ path: string;
31
+ /** Parser's 1-based line and column of the failure, when known. */
32
+ line?: number;
33
+ column?: number;
34
+ reason: string;
35
+ }
23
36
  export interface WorkspaceDoctorSection {
24
37
  root: string;
25
38
  mcp: WorkspaceLayerEntry[];
@@ -38,6 +51,8 @@ export interface WorkspaceDoctorSection {
38
51
  name: string;
39
52
  }[];
40
53
  unmigrated: WorkspaceUnmigratedHint[];
54
+ /** Parse-level failures on workspace agent MCP files (usually empty). */
55
+ tomlFindings: WorkspaceTomlFinding[];
41
56
  /** zcode's user-level-first deviation notices (empty otherwise). */
42
57
  precedenceWarnings: string[];
43
58
  }
@@ -10,6 +10,7 @@
10
10
  * from the already-loaded canonical source and file reads only.
11
11
  */
12
12
  import { existsSync, readFileSync } from "node:fs";
13
+ import { parse as parseToml } from "smol-toml";
13
14
  import { resolveSecretEnv } from "./secretEnv.js";
14
15
  import { loadWorkspaceRendered, workspaceMcpConfigPath, workspacePrecedenceWarnings } from "./workspaceMcp.js";
15
16
  const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
@@ -36,15 +37,36 @@ function collectVarRefs(canonical) {
36
37
  return refs;
37
38
  }
38
39
  /** Entry names a JSON `mcpServers` file / Codex TOML / zcode nested
39
- * config holds beyond what the in-repo manifest records Trellis
40
- * rendering. Regex-parses Codex TOML section headers — doctor only
41
- * needs names for a hint, not a full TOML AST. */
40
+ * config holds beyond what the in-repo manifest records Trellis rendering.
41
+ *
42
+ * The Codex branch TOML-parses the file first — syntactically invalid TOML
43
+ * is indistinguishable from valid TOML to the section-header regex, but is
44
+ * fatal to Codex's own startup, so it is surfaced as `invalid` and never
45
+ * turned into an unmigrated hint. The parsed AST is discarded on success:
46
+ * header extraction keeps using the existing regex to avoid changing edge
47
+ * semantics (dotted quoted keys, single-quoted keys) that the regex was
48
+ * already validated against — smol-toml is used parse-only, never
49
+ * round-tripped, respecting lib/tomlSection.ts's write-path constraint. */
42
50
  function unmarkedEntries(agentId, configPath, recorded) {
43
51
  if (!existsSync(configPath))
44
- return [];
52
+ return { kind: "ok", names: [] };
45
53
  let names = [];
46
54
  if (agentId === "codex") {
47
55
  const content = readFileSync(configPath, "utf-8");
56
+ try {
57
+ parseToml(content);
58
+ }
59
+ catch (err) {
60
+ const tomLError = err;
61
+ const reason = tomLError.message?.split("\n")[0] ?? String(err);
62
+ return {
63
+ kind: "invalid",
64
+ path: configPath,
65
+ ...(typeof tomLError.line === "number" ? { line: tomLError.line } : {}),
66
+ ...(typeof tomLError.column === "number" ? { column: tomLError.column + 1 } : {}),
67
+ reason,
68
+ };
69
+ }
48
70
  const re = /^\[mcp_servers\.([^\]]+)\]\s*$/gm;
49
71
  let match;
50
72
  while ((match = re.exec(content)) !== null)
@@ -59,10 +81,10 @@ function unmarkedEntries(agentId, configPath, recorded) {
59
81
  names = Object.keys(servers);
60
82
  }
61
83
  catch {
62
- return []; // unparsable file is the sync guard's finding, not doctor's
84
+ return { kind: "ok", names: [] }; // unparsable file is the sync guard's finding, not doctor's
63
85
  }
64
86
  }
65
- return names.filter((name) => recorded[name] === undefined);
87
+ return { kind: "ok", names: names.filter((name) => recorded[name] === undefined) };
66
88
  }
67
89
  export function collectWorkspaceDoctorSection(canonical) {
68
90
  const workspace = canonical.workspace;
@@ -83,12 +105,17 @@ export function collectWorkspaceDoctorSection(canonical) {
83
105
  const unresolvedVars = refs.filter((r) => resolved[r.name] === undefined);
84
106
  const manifest = loadWorkspaceRendered(workspace.root);
85
107
  const unmigrated = [];
108
+ const tomlFindings = [];
86
109
  for (const agentId of canonical.managedAgents) {
87
110
  const configPath = workspaceMcpConfigPath(agentId, workspace.root);
88
- const entries = unmarkedEntries(agentId, configPath, manifest.mcp?.[agentId] ?? {});
89
- if (entries.length > 0)
90
- unmigrated.push({ agent: agentId, path: configPath, entries });
111
+ const result = unmarkedEntries(agentId, configPath, manifest.mcp?.[agentId] ?? {});
112
+ if (result.kind === "invalid") {
113
+ tomlFindings.push({ agent: agentId, path: result.path, ...(result.line !== undefined ? { line: result.line } : {}), ...(result.column !== undefined ? { column: result.column } : {}), reason: result.reason });
114
+ continue; // never report an unparseable file's entries as unmigrated — spec req 2
115
+ }
116
+ if (result.names.length > 0)
117
+ unmigrated.push({ agent: agentId, path: configPath, entries: result.names });
91
118
  }
92
119
  const precedenceWarnings = canonical.managedAgents.flatMap((id) => workspacePrecedenceWarnings(id, canonical));
93
- return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, precedenceWarnings };
120
+ return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, tomlFindings, precedenceWarnings };
94
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",
@@ -64,6 +64,7 @@
64
64
  "@modelcontextprotocol/client": "^2.3.1",
65
65
  "@modelcontextprotocol/sdk": "^1.30.0",
66
66
  "@modelcontextprotocol/server": "^2.3.1",
67
+ "smol-toml": "^1.9.1",
67
68
  "yaml": "^2.9.1",
68
69
  "zod": "^4.6.5"
69
70
  }