agent-trellis 0.14.1 → 0.15.1

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 (60) hide show
  1. package/dist/adapters/claude-code.js +4 -3
  2. package/dist/adapters/codex.js +4 -3
  3. package/dist/adapters/kimi-code.js +4 -3
  4. package/dist/adapters/kiro.js +5 -4
  5. package/dist/adapters/pi.js +4 -3
  6. package/dist/adapters/zcode.d.ts +12 -1
  7. package/dist/adapters/zcode.js +7 -6
  8. package/dist/cli.js +52 -37
  9. package/dist/commands/doctor.d.ts +9 -1
  10. package/dist/commands/doctor.js +43 -6
  11. package/dist/commands/init.d.ts +6 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/mcp.d.ts +69 -3
  14. package/dist/commands/mcp.js +255 -56
  15. package/dist/commands/mcpGateway.js +3 -3
  16. package/dist/commands/migrate.d.ts +12 -0
  17. package/dist/commands/migrate.js +8 -4
  18. package/dist/commands/migrateWorkspace.d.ts +40 -0
  19. package/dist/commands/migrateWorkspace.js +197 -0
  20. package/dist/commands/rollback.js +65 -35
  21. package/dist/commands/skill.d.ts +14 -0
  22. package/dist/commands/skill.js +71 -0
  23. package/dist/commands/sync.d.ts +16 -1
  24. package/dist/commands/sync.js +48 -15
  25. package/dist/core/adapter.d.ts +14 -0
  26. package/dist/core/canonical.d.ts +20 -34
  27. package/dist/core/canonical.js +18 -92
  28. package/dist/core/mcpServerDef.d.ts +39 -0
  29. package/dist/core/mcpServerDef.js +93 -0
  30. package/dist/core/types.d.ts +39 -4
  31. package/dist/lib/cliArgs.d.ts +17 -0
  32. package/dist/lib/cliArgs.js +36 -0
  33. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  34. package/dist/lib/mcpMigrateRead.js +9 -2
  35. package/dist/lib/scopeSelection.d.ts +1 -1
  36. package/dist/lib/scopeSelection.js +4 -3
  37. package/dist/lib/skillProvider.js +18 -5
  38. package/dist/lib/workspace.d.ts +39 -0
  39. package/dist/lib/workspace.js +158 -0
  40. package/dist/lib/workspaceConfig.d.ts +51 -0
  41. package/dist/lib/workspaceConfig.js +117 -0
  42. package/dist/lib/workspaceDoctor.d.ts +44 -0
  43. package/dist/lib/workspaceDoctor.js +94 -0
  44. package/dist/lib/workspaceFiles.d.ts +38 -0
  45. package/dist/lib/workspaceFiles.js +264 -0
  46. package/dist/lib/workspaceMcp.d.ts +57 -0
  47. package/dist/lib/workspaceMcp.js +277 -0
  48. package/dist/lib/workspaceMerge.d.ts +37 -0
  49. package/dist/lib/workspaceMerge.js +98 -0
  50. package/dist/lib/workspaceMigrate.d.ts +118 -0
  51. package/dist/lib/workspaceMigrate.js +423 -0
  52. package/dist/pi-bridge/bundle.js +258 -96
  53. package/docs/architecture.md +111 -49
  54. package/docs/getting-started.md +86 -14
  55. package/docs/mcp-oauth-matrix.md +12 -1
  56. package/docs/overview.zh-CN.md +17 -0
  57. package/docs/research.md +28 -6
  58. package/docs/roadmap.md +17 -5
  59. package/package.json +1 -1
  60. package/docs/implementation-plan.md +0 -341
@@ -14,6 +14,9 @@ import { PiAdapter } from "../adapters/pi.js";
14
14
  import { KimiCodeAdapter } from "../adapters/kimi-code.js";
15
15
  import { ZcodeAdapter } from "../adapters/zcode.js";
16
16
  import { openBackupSession } from "../lib/backup.js";
17
+ import { findWorkspaceRoot } from "../lib/workspace.js";
18
+ import { applyWorkspaceFiles, planWorkspaceFiles } from "../lib/workspaceFiles.js";
19
+ import { workspacePrecedenceWarnings } from "../lib/workspaceMcp.js";
17
20
  const ADAPTER_FACTORY = {
18
21
  "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
19
22
  codex: (homeDir) => new CodexAdapter(homeDir),
@@ -30,7 +33,11 @@ function buildAdapters(homeDir, managedAgents) {
30
33
  }
31
34
  export async function collectSyncReport(opts = {}) {
32
35
  const homeDir = opts.homeDir ?? homedir();
33
- const loaded = loadCanonicalSource(homeDir);
36
+ // Workspace participation is automatic inside a repo carrying `.agents/`
37
+ // and revocable via --global-only; outside any workspace (or when it
38
+ // resolves null) the load is byte-identical to before.
39
+ const workspaceDir = opts.globalOnly ? null : (opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd()));
40
+ const loaded = loadCanonicalSource({ homeDir, workspaceDir });
34
41
  // A single override point, not two: every downstream read of "who's
35
42
  // managed" — the adapter list built here AND each adapter's own
36
43
  // in-scope filtering via `canonical.managedAgents` — must agree, or a
@@ -39,9 +46,10 @@ export async function collectSyncReport(opts = {}) {
39
46
  const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
40
47
  const reports = [];
41
48
  // Own session only when the caller didn't share one (trellis-backup-
42
- // rollback D2) — dry-run never opens or writes one, there's nothing to
43
- // record.
44
- const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
49
+ // rollback D2) — dry-run/check never opens or writes one, there's
50
+ // nothing to record.
51
+ const writeMode = !opts.dryRun && !opts.check;
52
+ const ownSession = writeMode && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
45
53
  const backup = opts.backupSession ?? ownSession;
46
54
  for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
47
55
  const probeResult = await adapter.probe();
@@ -53,6 +61,7 @@ export async function collectSyncReport(opts = {}) {
53
61
  // `trellis sync` never touches MCP, that's `trellis mcp sync`'s own
54
62
  // command, so exclude it unconditionally before any target filter.
55
63
  let items = (await adapter.plan(canonical)).filter((item) => item.kind !== "mcp");
64
+ let wsItems = planWorkspaceFiles(adapter.id, canonical);
56
65
  if (opts.target) {
57
66
  // CLI/option vocabulary is plural ("skills"/"instructions",
58
67
  // matching `trellis sync skills`); AdapterPlanItem.kind is singular
@@ -63,14 +72,18 @@ export async function collectSyncReport(opts = {}) {
63
72
  // sync" even with real create items pending).
64
73
  const kind = opts.target === "skills" ? "skill" : "instructions";
65
74
  items = items.filter((item) => item.kind === kind || (kind === "skill" && item.kind === "zcode-skill-control"));
75
+ wsItems = wsItems.filter((item) => item.kind === kind);
66
76
  }
67
- if (!opts.dryRun) {
77
+ const allItems = [...items, ...wsItems];
78
+ if (writeMode) {
68
79
  await adapter.apply(items, backup);
80
+ applyWorkspaceFiles(adapter.id, canonical, wsItems, backup);
69
81
  }
70
- reports.push({ agent: adapter.id, present: true, items });
82
+ const warnings = workspacePrecedenceWarnings(adapter.id, canonical);
83
+ reports.push({ agent: adapter.id, present: true, items: allItems, ...(warnings.length > 0 ? { warnings } : {}) });
71
84
  }
72
85
  ownSession?.finalize();
73
- return { reports };
86
+ return { reports, ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
74
87
  }
75
88
  export async function runSync(opts = {}) {
76
89
  let report;
@@ -85,22 +98,34 @@ export async function runSync(opts = {}) {
85
98
  console.log(JSON.stringify(report, null, 2));
86
99
  }
87
100
  else {
88
- printReport(report, opts.dryRun ?? false);
101
+ printReport(report, opts.dryRun ?? false, opts.check ?? false);
89
102
  }
90
103
  const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
104
+ if (opts.check) {
105
+ // Freshness gate: any pending write (create/remove) or conflict means
106
+ // the rendered artifacts are stale — exit non-zero, having written
107
+ // nothing.
108
+ const hasPending = report.reports.some((r) => r.items.some((i) => i.action !== "conflict"));
109
+ return { exitCode: hasPending || hasConflict ? 1 : 0 };
110
+ }
91
111
  return { exitCode: hasConflict ? 1 : 0 };
92
112
  }
93
113
  /** Exported so `onboard` prints a sync report identically to running
94
114
  * `sync` standalone, instead of a second, easily-drifting copy of this
95
115
  * formatting. */
96
- export function printReport(report, dryRun) {
116
+ export function printReport(report, dryRun, check = false) {
97
117
  if (dryRun)
98
118
  console.log("[dry run]");
119
+ if (check)
120
+ console.log("[check]");
121
+ if (report.workspaceRoot) {
122
+ console.log(`Workspace layer: ${report.workspaceRoot} (.agents/)`);
123
+ }
99
124
  if (report.reports.length === 0) {
100
125
  console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
101
126
  return;
102
127
  }
103
- for (const { agent, present, items } of report.reports) {
128
+ for (const { agent, present, items, warnings } of report.reports) {
104
129
  if (!present) {
105
130
  console.log(`— ${agent} (not installed)`);
106
131
  continue;
@@ -110,12 +135,20 @@ export function printReport(report, dryRun) {
110
135
  const conflicts = items.filter((i) => i.action === "conflict");
111
136
  if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
112
137
  console.log(`✅ ${agent} — already in sync`);
113
- continue;
114
138
  }
115
- const icon = conflicts.length > 0 ? "⚠️ " : "✅";
116
- console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
117
- for (const item of [...created, ...removed, ...conflicts]) {
118
- console.log(` - [${item.action}] ${item.description}`);
139
+ else {
140
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
141
+ console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
142
+ for (const item of [...created, ...removed, ...conflicts]) {
143
+ console.log(` - [${item.action}]${item.layer === "workspace" ? " (workspace)" : ""} ${item.description}`);
144
+ // Same as mcp.ts's printer: a conflict's remediation IS the fix —
145
+ // never leave the user at "left untouched" with no next step.
146
+ if (item.remediation)
147
+ console.log(` → ${item.remediation}`);
148
+ }
149
+ }
150
+ for (const warning of warnings ?? []) {
151
+ console.log(` ⚠ ${warning}`);
119
152
  }
120
153
  }
121
154
  }
@@ -98,6 +98,20 @@ export interface AdapterPlanItem {
98
98
  skillsEnabled: boolean | null;
99
99
  };
100
100
  };
101
+ /** Set when this item renders the workspace (`.agents/`) layer rather
102
+ * than the global one (trellis-workspace-scope) — the command layer
103
+ * routes these to the workspace apply functions, never to the agent's
104
+ * global adapter apply. */
105
+ layer?: "workspace";
106
+ /** Only set when `layer === "workspace"`, `kind === "skill"`, and
107
+ * `action === "create"`: the canonical directory to copy into `target`
108
+ * (workspace skills are COPIED, never symlinked — rendered artifacts
109
+ * must be committable and Windows-safe, design D5). */
110
+ copyFrom?: string;
111
+ /** Only set when `layer === "workspace"`, `kind === "instructions"`, and
112
+ * `action === "create"`: the full rendered file content (managed marker
113
+ * header + workspace AGENTS.md body) to write at `target`. */
114
+ fileContent?: string;
101
115
  }
102
116
  export interface AdapterVerifyResult {
103
117
  ok: boolean;
@@ -1,40 +1,14 @@
1
1
  /**
2
- * Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
3
- * parameter, no workspace merge (docs/architecture.md "Global vs.
4
- * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
- * canonical-source-loading/spec.md for the exact contract this implements.
2
+ * Loads `~/.trellis/` into a `CanonicalSource`. Global-only by default —
3
+ * no working-directory consultation; a caller that explicitly discovered a
4
+ * workspace root (src/lib/workspace.ts `findWorkspaceRoot`) may pass it in
5
+ * to layer `<workspace>/.agents/` over the global source
6
+ * (openspec/changes/trellis-workspace-scope, design D2).
6
7
  */
7
8
  import type { AgentId, CapabilityDelivery, CanonicalSource, McpRoute, McpServerDef, Scope } from "./types.js";
8
9
  import type { BackupSession } from "../lib/backup.js";
9
- /**
10
- * The on-disk shape for one server entry — `static_env`/`env_aliases`
11
- * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
12
- * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
13
- * below, and `auth`'s object form carries `client_id`/`client_secret_env`/
14
- * `client_name` the same way (trellis-mcp-static-env-and-disabled-servers,
15
- * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client,
16
- * oauth-client-name).
17
- */
18
- type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
19
- static_env?: Record<string, string>;
20
- env_aliases?: Record<string, string>;
21
- auth?: McpAuthYaml;
22
- };
23
- /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
24
- * form whose keys are snake_case like every other multi-word key in
25
- * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
26
- * them — a YAML cast is optimistic, validation is this module's job. */
27
- type McpAuthYaml = string | {
28
- kind?: unknown;
29
- owner?: unknown;
30
- client_id?: unknown;
31
- client_secret_env?: unknown;
32
- client_name?: unknown;
33
- };
34
- /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
35
- * `undefined` fields so the written YAML never gets a literal `null`
36
- * for an omitted optional. */
37
- export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
10
+ export { toServerDefYaml } from "./mcpServerDef.js";
11
+ export type { McpServerDefYaml } from "./mcpServerDef.js";
38
12
  export type ServersYamlWriteResult = {
39
13
  ok: true;
40
14
  } | {
@@ -140,6 +114,18 @@ export declare function ensureGitignoreEntry(gitignorePath: string, line: string
140
114
  * `export` prefix of its own.
141
115
  */
142
116
  export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
117
+ /**
118
+ * Options form of `loadCanonicalSource` (trellis-workspace-scope design D2).
119
+ * `homeDir` anchors BOTH the global canonical root and user-home semantics
120
+ * (secrets `env_file` `~` expansion) at all times; `workspaceDir` — a root
121
+ * previously discovered via `findWorkspaceRoot` — only adds the `.agents/`
122
+ * project layer on top, never relocates either.
123
+ */
124
+ export interface LoadCanonicalOptions {
125
+ homeDir?: string;
126
+ workspaceDir?: string | null;
127
+ }
143
128
  /** Load the global canonical source from the supplied home directory. */
144
129
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
145
- export {};
130
+ /** Load the global canonical source, optionally layered with an explicit workspace's `.agents/`. */
131
+ export declare function loadCanonicalSource(options: LoadCanonicalOptions): CanonicalSource;
@@ -1,99 +1,19 @@
1
1
  /**
2
- * Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
3
- * parameter, no workspace merge (docs/architecture.md "Global vs.
4
- * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
- * canonical-source-loading/spec.md for the exact contract this implements.
2
+ * Loads `~/.trellis/` into a `CanonicalSource`. Global-only by default —
3
+ * no working-directory consultation; a caller that explicitly discovered a
4
+ * workspace root (src/lib/workspace.ts `findWorkspaceRoot`) may pass it in
5
+ * to layer `<workspace>/.agents/` over the global source
6
+ * (openspec/changes/trellis-workspace-scope, design D2).
6
7
  */
7
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { basename, dirname, join } from "node:path";
10
11
  import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
11
12
  import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
12
- import { isValidSecretVarName } from "../lib/secretEnv.js";
13
- function fromServerDefYaml(serverName, def) {
14
- const { static_env, env_aliases, auth, ...rest } = def;
15
- const out = { ...rest };
16
- if (static_env)
17
- out.staticEnv = static_env;
18
- if (env_aliases)
19
- out.envAliases = env_aliases;
20
- if (auth !== undefined)
21
- out.auth = fromAuthYaml(serverName, auth);
22
- return out;
23
- }
24
- /**
25
- * `auth` translation and shape validation
26
- * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
27
- * object form share one discriminator — `kind: oauth` — and the object
28
- * accepts exactly `kind`, `owner`, `client_id`, `client_secret_env`. Everything
29
- * else is refused here at load, naming the field but never echoing the
30
- * value: an invalid `client_secret_env` is at best a typo, at worst a
31
- * pasted secret.
32
- */
33
- function fromAuthYaml(serverName, auth) {
34
- if (auth === "oauth")
35
- return "oauth";
36
- if (typeof auth !== "object" || auth === null) {
37
- throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env?, client_name? }`);
38
- }
39
- const { kind, owner, client_id, client_secret_env, client_name, ...extra } = auth;
40
- const unknownKey = Object.keys(extra)[0];
41
- if (unknownKey !== undefined) {
42
- throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env, client_name`);
43
- }
44
- if (kind !== "oauth") {
45
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
46
- }
47
- if (client_id !== undefined && typeof client_id !== "string") {
48
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_id must be a string`);
49
- }
50
- if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
51
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
52
- }
53
- if (client_name !== undefined && typeof client_name !== "string") {
54
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_name must be a string`);
55
- }
56
- const out = { kind: "oauth" };
57
- if (owner !== undefined && owner !== "agent" && owner !== "trellis")
58
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.owner must be agent or trellis`);
59
- if (owner !== undefined)
60
- out.owner = owner;
61
- if (typeof client_id === "string")
62
- out.clientId = client_id;
63
- if (typeof client_secret_env === "string")
64
- out.clientSecretEnv = client_secret_env;
65
- if (typeof client_name === "string")
66
- out.clientName = client_name;
67
- return out;
68
- }
69
- /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
70
- * `undefined` fields so the written YAML never gets a literal `null`
71
- * for an omitted optional. */
72
- export function toServerDefYaml(def) {
73
- const { staticEnv, envAliases, auth, ...rest } = def;
74
- const out = { ...rest };
75
- if (staticEnv)
76
- out.static_env = staticEnv;
77
- if (envAliases)
78
- out.env_aliases = envAliases;
79
- if (auth !== undefined) {
80
- out.auth =
81
- typeof auth === "string"
82
- ? auth
83
- : {
84
- kind: auth.kind,
85
- ...(auth.owner !== undefined ? { owner: auth.owner } : {}),
86
- ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
87
- ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
88
- ...(auth.clientName !== undefined ? { client_name: auth.clientName } : {}),
89
- };
90
- }
91
- for (const key of Object.keys(out)) {
92
- if (out[key] === undefined)
93
- delete out[key];
94
- }
95
- return out;
96
- }
13
+ import { fromServerDefYaml, toServerDefYaml } from "./mcpServerDef.js";
14
+ import { loadWorkspaceLayer } from "../lib/workspace.js";
15
+ import { mergeWorkspaceLayer } from "../lib/workspaceMerge.js";
16
+ export { toServerDefYaml } from "./mcpServerDef.js";
97
17
  function trellisRoot(homeDir) {
98
18
  return join(homeDir, ".trellis");
99
19
  }
@@ -565,8 +485,9 @@ function assertAuthMetadataClean(mcp, policy) {
565
485
  }
566
486
  }
567
487
  }
568
- /** Load the global canonical source from the supplied home directory. */
569
- export function loadCanonicalSource(homeDir = homedir()) {
488
+ export function loadCanonicalSource(arg = {}) {
489
+ const options = typeof arg === "string" ? { homeDir: arg } : arg;
490
+ const homeDir = options.homeDir ?? homedir();
570
491
  const root = trellisRoot(homeDir);
571
492
  if (!existsSync(root)) {
572
493
  throw new Error(`No canonical source at ${root}. Run trellis init before trellis sync.`);
@@ -609,7 +530,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
609
530
  const mcp = loadServersYaml(join(root, "mcp", "servers.yaml"));
610
531
  const secretsPolicy = loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir);
611
532
  assertAuthMetadataClean(mcp, secretsPolicy);
612
- return {
533
+ const source = {
613
534
  instructionsFile: join(root, "agents.md"),
614
535
  managedAgents,
615
536
  skills,
@@ -619,4 +540,9 @@ export function loadCanonicalSource(homeDir = homedir()) {
619
540
  secretsPolicy,
620
541
  diagnostics,
621
542
  };
543
+ if (options.workspaceDir) {
544
+ const layer = loadWorkspaceLayer(options.workspaceDir, secretsPolicy);
545
+ return mergeWorkspaceLayer(source, layer);
546
+ }
547
+ return source;
622
548
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * On-disk ⇄ canonical translation for one MCP server entry, shared by
3
+ * `~/.trellis/mcp/servers.yaml` (YAML) and `<workspace>/.agents/config.json`
4
+ * (JSON). Both use the identical snake_case field set so there is exactly
5
+ * one server schema and one validator (trellis-workspace-scope design D1);
6
+ * the `source` label exists only so diagnostics name the file the user
7
+ * actually edited.
8
+ */
9
+ import type { McpServerDef } from "./types.js";
10
+ /**
11
+ * The on-disk shape for one server entry — `static_env`/`env_aliases`
12
+ * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
13
+ * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
14
+ * below, and `auth`'s object form carries `client_id`/`client_secret_env`/
15
+ * `client_name` the same way (trellis-mcp-static-env-and-disabled-servers,
16
+ * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client,
17
+ * oauth-client-name).
18
+ */
19
+ export type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
20
+ static_env?: Record<string, string>;
21
+ env_aliases?: Record<string, string>;
22
+ auth?: McpAuthYaml;
23
+ };
24
+ /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
25
+ * form whose keys are snake_case like every other multi-word key in
26
+ * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
27
+ * them — a YAML/JSON cast is optimistic, validation is this module's job. */
28
+ export type McpAuthYaml = string | {
29
+ kind?: unknown;
30
+ owner?: unknown;
31
+ client_id?: unknown;
32
+ client_secret_env?: unknown;
33
+ client_name?: unknown;
34
+ };
35
+ export declare function fromServerDefYaml(serverName: string, def: McpServerDefYaml, source?: string): McpServerDef;
36
+ /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
37
+ * `undefined` fields so the written file never gets a literal `null`
38
+ * for an omitted optional. */
39
+ export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * On-disk ⇄ canonical translation for one MCP server entry, shared by
3
+ * `~/.trellis/mcp/servers.yaml` (YAML) and `<workspace>/.agents/config.json`
4
+ * (JSON). Both use the identical snake_case field set so there is exactly
5
+ * one server schema and one validator (trellis-workspace-scope design D1);
6
+ * the `source` label exists only so diagnostics name the file the user
7
+ * actually edited.
8
+ */
9
+ import { isValidSecretVarName } from "../lib/secretEnv.js";
10
+ export function fromServerDefYaml(serverName, def, source = "mcp/servers.yaml") {
11
+ const { static_env, env_aliases, auth, ...rest } = def;
12
+ const out = { ...rest };
13
+ if (static_env)
14
+ out.staticEnv = static_env;
15
+ if (env_aliases)
16
+ out.envAliases = env_aliases;
17
+ if (auth !== undefined)
18
+ out.auth = fromAuthYaml(serverName, auth, source);
19
+ return out;
20
+ }
21
+ /**
22
+ * `auth` translation and shape validation
23
+ * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
24
+ * object form share one discriminator — `kind: oauth` — and the object
25
+ * accepts exactly `kind`, `owner`, `client_id`, `client_secret_env`. Everything
26
+ * else is refused here at load, naming the field but never echoing the
27
+ * value: an invalid `client_secret_env` is at best a typo, at worst a
28
+ * pasted secret.
29
+ */
30
+ function fromAuthYaml(serverName, auth, source) {
31
+ if (auth === "oauth")
32
+ return "oauth";
33
+ if (typeof auth !== "object" || auth === null) {
34
+ throw new Error(`${source}: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env?, client_name? }`);
35
+ }
36
+ const { kind, owner, client_id, client_secret_env, client_name, ...extra } = auth;
37
+ const unknownKey = Object.keys(extra)[0];
38
+ if (unknownKey !== undefined) {
39
+ throw new Error(`${source}: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env, client_name`);
40
+ }
41
+ if (kind !== "oauth") {
42
+ throw new Error(`${source}: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
43
+ }
44
+ if (client_id !== undefined && typeof client_id !== "string") {
45
+ throw new Error(`${source}: "${serverName}" auth.client_id must be a string`);
46
+ }
47
+ if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
48
+ throw new Error(`${source}: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
49
+ }
50
+ if (client_name !== undefined && typeof client_name !== "string") {
51
+ throw new Error(`${source}: "${serverName}" auth.client_name must be a string`);
52
+ }
53
+ const out = { kind: "oauth" };
54
+ if (owner !== undefined && owner !== "agent" && owner !== "trellis")
55
+ throw new Error(`${source}: "${serverName}" auth.owner must be agent or trellis`);
56
+ if (owner !== undefined)
57
+ out.owner = owner;
58
+ if (typeof client_id === "string")
59
+ out.clientId = client_id;
60
+ if (typeof client_secret_env === "string")
61
+ out.clientSecretEnv = client_secret_env;
62
+ if (typeof client_name === "string")
63
+ out.clientName = client_name;
64
+ return out;
65
+ }
66
+ /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
67
+ * `undefined` fields so the written file never gets a literal `null`
68
+ * for an omitted optional. */
69
+ export function toServerDefYaml(def) {
70
+ const { staticEnv, envAliases, auth, ...rest } = def;
71
+ const out = { ...rest };
72
+ if (staticEnv)
73
+ out.static_env = staticEnv;
74
+ if (envAliases)
75
+ out.env_aliases = envAliases;
76
+ if (auth !== undefined) {
77
+ out.auth =
78
+ typeof auth === "string"
79
+ ? auth
80
+ : {
81
+ kind: auth.kind,
82
+ ...(auth.owner !== undefined ? { owner: auth.owner } : {}),
83
+ ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
84
+ ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
85
+ ...(auth.clientName !== undefined ? { client_name: auth.clientName } : {}),
86
+ };
87
+ }
88
+ for (const key of Object.keys(out)) {
89
+ if (out[key] === undefined)
90
+ delete out[key];
91
+ }
92
+ return out;
93
+ }
@@ -303,10 +303,11 @@ export interface AgentSnapshot {
303
303
  }
304
304
  export interface CanonicalSource {
305
305
  /**
306
- * Global (`~/.trellis`) only for now. Project-local `.trellis/` and its
307
- * merge-over-global precedence are an explicit non-goal until a later
308
- * phase — see docs/roadmap.md. Don't build workspace resolution ahead of
309
- * that decision.
306
+ * Global (`~/.trellis`) by default. When the caller explicitly supplies a
307
+ * discovered workspace root, the `<workspace>/.agents/` project layer is
308
+ * merged over the global layer (workspace wins by ID, `enabled: false`
309
+ * tombstones) and `workspace` below carries the merge's provenance — see
310
+ * openspec/changes/trellis-workspace-scope.
310
311
  */
311
312
  instructionsFile: string;
312
313
  /** From `~/.trellis/managed.yaml`. Missing file and `agents: []` both
@@ -317,7 +318,41 @@ export interface CanonicalSource {
317
318
  memories: MemoryEntry[];
318
319
  mcp: McpConfig;
319
320
  secretsPolicy: SecretsPolicy;
321
+ /** Present only when the source was loaded with a workspace layer. */
322
+ workspace?: WorkspaceLayerInfo;
320
323
  /** e.g. a `scope.yaml` entry naming a skill/agent/memory that doesn't
321
324
  * exist — recorded, not a load failure. See `loadCanonicalSource`. */
322
325
  diagnostics: string[];
323
326
  }
327
+ export type LayerProvenance = "global" | "workspace";
328
+ /** Merge metadata produced when a workspace `.agents/` layer is in scope. */
329
+ export interface WorkspaceLayerInfo {
330
+ /** Absolute path of the workspace root (the directory containing `.agents/`). */
331
+ root: string;
332
+ /** `<root>/.agents/AGENTS.md`, when the workspace declares instructions. */
333
+ instructionsFile?: string;
334
+ /** Per-server source layer for every entry in `mcp.servers`. */
335
+ mcpProvenance: Record<string, LayerProvenance>;
336
+ /** The pre-merge GLOBAL layer's server map — what global (user-level)
337
+ * delivery must render, layer-preserving (workspace-delivery spec:
338
+ * "global config files are untouched by the workspace entries"). Includes
339
+ * shadowed entries with their GLOBAL definitions (zcode resolves same-name
340
+ * conflicts user-first, so the global def is what zcode will actually use)
341
+ * and tombstoned entries (a tombstone only removes the server from the
342
+ * workspace's effective view; the shared user-level files still carry the
343
+ * global def for every other context). */
344
+ mcpGlobalServers: Record<string, McpServerDef>;
345
+ /** Per-skill source layer for every entry in `skills`. */
346
+ skillProvenance: Record<string, LayerProvenance>;
347
+ /** The pre-merge GLOBAL layer's skills — what global (user-level)
348
+ * delivery must symlink, layer-preserving, same invariant as
349
+ * `mcpGlobalServers`. Includes entries the workspace shadows, with their
350
+ * GLOBAL definitions: the user-level skill dirs are shared by every
351
+ * project, so a workspace override only governs project-level delivery.
352
+ * (zcode's Runtime SkillProvider is the deliberate exception — it serves
353
+ * the merged view by design, trellis-workspace-scope task 2.2.) */
354
+ globalSkills: SkillRef[];
355
+ /** Global entries the workspace overrode or tombstoned, as
356
+ * `"mcp:<name>"` / `"skill:<name>"` — surfaced by doctor/status. */
357
+ shadowed: string[];
358
+ }
@@ -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
+ }
@@ -35,6 +35,30 @@ export interface McpMigrateReadResult {
35
35
  * independent derivations of it.
36
36
  */
37
37
  export declare function resolvedEnvTextMap(def: Pick<McpServerDef, "env" | "envAliases" | "staticEnv">): Record<string, string>;
38
+ /** Shared by claude-code and kiro — both write the identical JSON shape
39
+ * (`jsonMcp.ts` is their common writer). Deliberately richer than either
40
+ * probe's own read-side type: adds `headers`, which both probes' own
41
+ * types currently omit despite it being a real field Trellis itself
42
+ * writes for http/sse servers into this exact shape (confirmed via
43
+ * `grep -n "headers" src/probes/kiro.ts src/probes/claude-code.ts
44
+ * src/adapters/jsonMcp.ts` — only the writer declares it). */
45
+ interface RichJsonServerDef {
46
+ type?: "stdio" | "http" | "sse";
47
+ command?: string;
48
+ args?: string[];
49
+ url?: string;
50
+ env?: Record<string, string>;
51
+ headers?: Record<string, string>;
52
+ }
53
+ /** Also exported for the workspace-migration scanner's zcode reader
54
+ * (trellis-workspace-scope task 4.1): zcode's project config nests the
55
+ * same rich-JSON server shape under `mcp.servers`, so the walker there
56
+ * reuses this per-entry mapping unchanged. */
57
+ export declare function fromRichJsonServerDef(raw: RichJsonServerDef): McpServerDef | undefined;
58
+ /** Also the workspace-migration scanner's JSON reader (trellis-workspace-
59
+ * scope task 4.1): project-level `.mcp.json`-shape files parse through
60
+ * the exact same rules as their global counterparts. */
61
+ export declare function readRichJsonMcpDefs(configPath: string): McpMigrateReadResult;
38
62
  export declare function readClaudeCodeMcpDefs(homeDir: string): McpMigrateReadResult;
39
63
  export declare function readKiroMcpDefs(homeDir: string): McpMigrateReadResult;
40
64
  export declare function readKimiCodeMcpDefs(homeDir: string): McpMigrateReadResult;
@@ -81,3 +105,4 @@ export declare function buildCodexMcpReadResult(mcpEntries: CodexMcpEntryRich[],
81
105
  * entry is reported as unsupported instead.
82
106
  */
83
107
  export declare function readCodexMcpDefs(homeDir: string): McpMigrateReadResult;
108
+ export {};