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.
- package/dist/adapters/claude-code.js +4 -3
- package/dist/adapters/codex.js +4 -3
- package/dist/adapters/kimi-code.js +4 -3
- package/dist/adapters/kiro.js +5 -4
- package/dist/adapters/pi.js +4 -3
- package/dist/adapters/zcode.d.ts +12 -1
- package/dist/adapters/zcode.js +7 -6
- package/dist/cli.js +52 -37
- package/dist/commands/doctor.d.ts +9 -1
- package/dist/commands/doctor.js +43 -6
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/mcp.d.ts +69 -3
- package/dist/commands/mcp.js +255 -56
- package/dist/commands/mcpGateway.js +3 -3
- package/dist/commands/migrate.d.ts +12 -0
- package/dist/commands/migrate.js +8 -4
- package/dist/commands/migrateWorkspace.d.ts +40 -0
- package/dist/commands/migrateWorkspace.js +197 -0
- package/dist/commands/rollback.js +65 -35
- package/dist/commands/skill.d.ts +14 -0
- package/dist/commands/skill.js +71 -0
- package/dist/commands/sync.d.ts +16 -1
- package/dist/commands/sync.js +48 -15
- package/dist/core/adapter.d.ts +14 -0
- package/dist/core/canonical.d.ts +20 -34
- package/dist/core/canonical.js +18 -92
- package/dist/core/mcpServerDef.d.ts +39 -0
- package/dist/core/mcpServerDef.js +93 -0
- package/dist/core/types.d.ts +39 -4
- package/dist/lib/cliArgs.d.ts +17 -0
- package/dist/lib/cliArgs.js +36 -0
- package/dist/lib/mcpMigrateRead.d.ts +25 -0
- package/dist/lib/mcpMigrateRead.js +9 -2
- package/dist/lib/scopeSelection.d.ts +1 -1
- package/dist/lib/scopeSelection.js +4 -3
- package/dist/lib/skillProvider.js +18 -5
- package/dist/lib/workspace.d.ts +39 -0
- package/dist/lib/workspace.js +158 -0
- package/dist/lib/workspaceConfig.d.ts +51 -0
- package/dist/lib/workspaceConfig.js +117 -0
- package/dist/lib/workspaceDoctor.d.ts +44 -0
- package/dist/lib/workspaceDoctor.js +94 -0
- package/dist/lib/workspaceFiles.d.ts +38 -0
- package/dist/lib/workspaceFiles.js +264 -0
- package/dist/lib/workspaceMcp.d.ts +57 -0
- package/dist/lib/workspaceMcp.js +277 -0
- package/dist/lib/workspaceMerge.d.ts +37 -0
- package/dist/lib/workspaceMerge.js +98 -0
- package/dist/lib/workspaceMigrate.d.ts +118 -0
- package/dist/lib/workspaceMigrate.js +423 -0
- package/dist/pi-bridge/bundle.js +258 -96
- package/docs/architecture.md +111 -49
- package/docs/getting-started.md +86 -14
- package/docs/mcp-oauth-matrix.md +12 -1
- package/docs/overview.zh-CN.md +17 -0
- package/docs/research.md +28 -6
- package/docs/roadmap.md +17 -5
- package/package.json +1 -1
- package/docs/implementation-plan.md +0 -341
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
43
|
-
// record.
|
|
44
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loads `~/.trellis/` into a `CanonicalSource`. Global
|
|
3
|
-
*
|
|
4
|
-
* workspace
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
/** Load the global canonical source, optionally layered with an explicit workspace's `.agents/`. */
|
|
131
|
+
export declare function loadCanonicalSource(options: LoadCanonicalOptions): CanonicalSource;
|
package/dist/core/canonical.js
CHANGED
|
@@ -1,99 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loads `~/.trellis/` into a `CanonicalSource`. Global
|
|
3
|
-
*
|
|
4
|
-
* workspace
|
|
5
|
-
*
|
|
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 {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -303,10 +303,11 @@ export interface AgentSnapshot {
|
|
|
303
303
|
}
|
|
304
304
|
export interface CanonicalSource {
|
|
305
305
|
/**
|
|
306
|
-
* Global (`~/.trellis`)
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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 {};
|