agent-trellis 0.14.1 → 0.15.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/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 +38 -5
- 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 +44 -1
- package/dist/commands/mcp.js +145 -33
- 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/mcpMigrateRead.d.ts +25 -0
- package/dist/lib/mcpMigrateRead.js +9 -2
- 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 +45 -0
- package/dist/lib/workspaceConfig.js +98 -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/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
|
+
}
|
|
@@ -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 {};
|
|
@@ -73,7 +73,11 @@ function splitJsonEnvMap(env) {
|
|
|
73
73
|
result.staticEnv = literal;
|
|
74
74
|
return result;
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
/** Also exported for the workspace-migration scanner's zcode reader
|
|
77
|
+
* (trellis-workspace-scope task 4.1): zcode's project config nests the
|
|
78
|
+
* same rich-JSON server shape under `mcp.servers`, so the walker there
|
|
79
|
+
* reuses this per-entry mapping unchanged. */
|
|
80
|
+
export function fromRichJsonServerDef(raw) {
|
|
77
81
|
if (raw.url) {
|
|
78
82
|
const transport = raw.type === "sse" ? "sse" : "http";
|
|
79
83
|
const def = { transport, url: raw.url };
|
|
@@ -89,7 +93,10 @@ function fromRichJsonServerDef(raw) {
|
|
|
89
93
|
Object.assign(def, splitJsonEnvMap(raw.env));
|
|
90
94
|
return def;
|
|
91
95
|
}
|
|
92
|
-
|
|
96
|
+
/** Also the workspace-migration scanner's JSON reader (trellis-workspace-
|
|
97
|
+
* scope task 4.1): project-level `.mcp.json`-shape files parse through
|
|
98
|
+
* the exact same rules as their global counterparts. */
|
|
99
|
+
export function readRichJsonMcpDefs(configPath) {
|
|
93
100
|
const parsed = readJsonFile(configPath);
|
|
94
101
|
const entries = [];
|
|
95
102
|
const unsupported = [];
|
|
@@ -4,10 +4,23 @@ import { resolve, relative, sep } from "node:path";
|
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
5
|
import { isInScope } from "../core/adapter.js";
|
|
6
6
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
7
|
+
import { findWorkspaceRoot } from "./workspace.js";
|
|
7
8
|
import { resolveScope } from "../core/types.js";
|
|
8
9
|
import { buildSkillEntry, parseSkillUri, readSkillFileBytes, toResourceContent } from "./skillManifest.js";
|
|
9
10
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
10
11
|
const SKILL_RESOURCE_PREFIX = "trellis://skills/";
|
|
12
|
+
/**
|
|
13
|
+
* The Runtime process is spawned by the agent with the agent's own cwd —
|
|
14
|
+
* inside a repo carrying `.agents/`, skills served over the Runtime
|
|
15
|
+
* (zcode's delivery path) include the workspace layer, exactly like the
|
|
16
|
+
* file-delivered agents get their project copies (trellis-workspace-scope
|
|
17
|
+
* task 2.2). MCP upstream connections are deliberately NOT layered this
|
|
18
|
+
* way (design D6: workspace servers are direct, agent-owned) — this
|
|
19
|
+
* helper is used only here, never in the gateway's upstream resolution.
|
|
20
|
+
*/
|
|
21
|
+
function loadSkillCanonical(homeDir) {
|
|
22
|
+
return loadCanonicalSource({ homeDir, workspaceDir: findWorkspaceRoot(process.cwd()) });
|
|
23
|
+
}
|
|
11
24
|
const SEARCH_TOOL = {
|
|
12
25
|
name: "trellis.skills.search",
|
|
13
26
|
description: "Search in-scope Trellis skills by name or description, then read one selected skill on demand.",
|
|
@@ -131,7 +144,7 @@ export class SkillProvider {
|
|
|
131
144
|
}
|
|
132
145
|
/** `skills/list` — one entry per servable, in-scope skill. */
|
|
133
146
|
listSkillEntries(context) {
|
|
134
|
-
return this.catalog(
|
|
147
|
+
return this.catalog(loadSkillCanonical(context.homeDir), context.agentId).map(({ entry }) => entry);
|
|
135
148
|
}
|
|
136
149
|
/** `skills/get` — lookup by URI, independent of listing. Out-of-scope and
|
|
137
150
|
* unknown URIs are indistinguishable by design. */
|
|
@@ -145,7 +158,7 @@ export class SkillProvider {
|
|
|
145
158
|
return [SEARCH_TOOL, READ_TOOL, READ_FILE_TOOL];
|
|
146
159
|
}
|
|
147
160
|
async callTool(name, args, context) {
|
|
148
|
-
const canonical =
|
|
161
|
+
const canonical = loadSkillCanonical(context.homeDir);
|
|
149
162
|
const input = (args ?? {});
|
|
150
163
|
if (name === SEARCH_TOOL.name)
|
|
151
164
|
return this.search(canonical, context.agentId, input);
|
|
@@ -156,7 +169,7 @@ export class SkillProvider {
|
|
|
156
169
|
return errorResult(`unknown skill provider tool "${name}"`);
|
|
157
170
|
}
|
|
158
171
|
listResources(context) {
|
|
159
|
-
const canonical =
|
|
172
|
+
const canonical = loadSkillCanonical(context.homeDir);
|
|
160
173
|
const legacy = canonical.skills
|
|
161
174
|
.filter((skill) => hasSkillDocument(skill) && isInScope(context.agentId, skill.scope, canonical.managedAgents))
|
|
162
175
|
.map((skill) => ({
|
|
@@ -181,7 +194,7 @@ export class SkillProvider {
|
|
|
181
194
|
if (segments.length !== 2 || segments[1] !== "SKILL.md") {
|
|
182
195
|
throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
|
|
183
196
|
}
|
|
184
|
-
const canonical =
|
|
197
|
+
const canonical = loadSkillCanonical(context.homeDir);
|
|
185
198
|
const skill = skillFor(canonical, context.agentId, segments[0]);
|
|
186
199
|
if (!skill)
|
|
187
200
|
throw new Error(`skill "${segments[0]}" is not available to ${context.agentId}`);
|
|
@@ -192,7 +205,7 @@ export class SkillProvider {
|
|
|
192
205
|
* computed its digests — the two cannot disagree. */
|
|
193
206
|
readConventionalResource(uri, context) {
|
|
194
207
|
const parsed = parseSkillUri(uri);
|
|
195
|
-
const canonical =
|
|
208
|
+
const canonical = loadSkillCanonical(context.homeDir);
|
|
196
209
|
const skill = parsed ? skillFor(canonical, context.agentId, parsed.name) : undefined;
|
|
197
210
|
const served = skill && parsed ? this.catalog(canonical, context.agentId).find(({ entry }) => entry.uri === `skill://${parsed.name}/SKILL.md`) : undefined;
|
|
198
211
|
if (!parsed || !skill || !served || !served.entry.resources.some((file) => file.uri === uri)) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace (project-level) canonical layer: discovery of the nearest
|
|
3
|
+
* `.agents/` directory and loading of its `config.json` / `AGENTS.md` /
|
|
4
|
+
* `skills/` into a `WorkspaceLayer` (openspec trellis-workspace-scope,
|
|
5
|
+
* design D1/D2). The shape follows the `.agents` Protocol draft's
|
|
6
|
+
* two-layer model; skills under `.agents/skills/` are additionally read
|
|
7
|
+
* natively by Codex, pi, and Kimi Code, so the canonical directory is
|
|
8
|
+
* itself the deliverable for those agents.
|
|
9
|
+
*/
|
|
10
|
+
import type { McpServerDef, SecretsPolicy, SkillRef } from "../core/types.js";
|
|
11
|
+
export declare const WORKSPACE_DIR_NAME = ".agents";
|
|
12
|
+
/** The only `config.json` schema version this implementation understands. */
|
|
13
|
+
export declare const WORKSPACE_CONFIG_VERSION = 1;
|
|
14
|
+
/**
|
|
15
|
+
* Walk ancestors of `startDir` upward; the first directory containing a
|
|
16
|
+
* `.agents/` directory is the workspace root. The user's home directory
|
|
17
|
+
* itself is never a workspace root (a `~/.agents/` global convention, if
|
|
18
|
+
* one ever exists, is a separate decision), and reaching the filesystem
|
|
19
|
+
* root yields `null` — pure global-only behavior.
|
|
20
|
+
*/
|
|
21
|
+
export declare function findWorkspaceRoot(startDir: string): string | null;
|
|
22
|
+
export interface WorkspaceLayer {
|
|
23
|
+
/** Absolute workspace root (the directory containing `.agents/`). */
|
|
24
|
+
root: string;
|
|
25
|
+
/** Validated MCP servers from `config.json`, minus tombstones. */
|
|
26
|
+
servers: Record<string, McpServerDef>;
|
|
27
|
+
/** Server names declared with `enabled: false` — they disable a same-ID
|
|
28
|
+
* global entry and render nothing themselves. */
|
|
29
|
+
tombstones: string[];
|
|
30
|
+
/** Skills from `.agents/skills/<name>/`. */
|
|
31
|
+
skills: SkillRef[];
|
|
32
|
+
/** `.agents/AGENTS.md`, when present. */
|
|
33
|
+
instructionsFile?: string;
|
|
34
|
+
diagnostics: string[];
|
|
35
|
+
}
|
|
36
|
+
/** Load `<workspaceDir>/.agents/` into a `WorkspaceLayer`. Every file is
|
|
37
|
+
* optional; a present-but-invalid `config.json` is a hard error (never a
|
|
38
|
+
* silent misread), with the secrets boundary enforced against `policy`. */
|
|
39
|
+
export declare function loadWorkspaceLayer(workspaceDir: string, policy: SecretsPolicy): WorkspaceLayer;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace (project-level) canonical layer: discovery of the nearest
|
|
3
|
+
* `.agents/` directory and loading of its `config.json` / `AGENTS.md` /
|
|
4
|
+
* `skills/` into a `WorkspaceLayer` (openspec trellis-workspace-scope,
|
|
5
|
+
* design D1/D2). The shape follows the `.agents` Protocol draft's
|
|
6
|
+
* two-layer model; skills under `.agents/skills/` are additionally read
|
|
7
|
+
* natively by Codex, pi, and Kimi Code, so the canonical directory is
|
|
8
|
+
* itself the deliverable for those agents.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { oauthClientMetadata } from "../core/types.js";
|
|
14
|
+
import { fromServerDefYaml } from "../core/mcpServerDef.js";
|
|
15
|
+
export const WORKSPACE_DIR_NAME = ".agents";
|
|
16
|
+
/** The only `config.json` schema version this implementation understands. */
|
|
17
|
+
export const WORKSPACE_CONFIG_VERSION = 1;
|
|
18
|
+
const CONFIG_SOURCE_LABEL = ".agents/config.json";
|
|
19
|
+
/**
|
|
20
|
+
* Walk ancestors of `startDir` upward; the first directory containing a
|
|
21
|
+
* `.agents/` directory is the workspace root. The user's home directory
|
|
22
|
+
* itself is never a workspace root (a `~/.agents/` global convention, if
|
|
23
|
+
* one ever exists, is a separate decision), and reaching the filesystem
|
|
24
|
+
* root yields `null` — pure global-only behavior.
|
|
25
|
+
*/
|
|
26
|
+
export function findWorkspaceRoot(startDir) {
|
|
27
|
+
const home = resolve(homedir());
|
|
28
|
+
let dir = resolve(startDir);
|
|
29
|
+
while (true) {
|
|
30
|
+
if (dir === home)
|
|
31
|
+
return null;
|
|
32
|
+
const candidate = join(dir, WORKSPACE_DIR_NAME);
|
|
33
|
+
try {
|
|
34
|
+
if (statSync(candidate).isDirectory())
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// not present or unreadable — keep walking
|
|
39
|
+
}
|
|
40
|
+
const parent = dirname(dir);
|
|
41
|
+
if (parent === dir)
|
|
42
|
+
return null;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isTombstone(def) {
|
|
47
|
+
return def.enabled === false && def.transport === undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Load `<workspaceDir>/.agents/` into a `WorkspaceLayer`. Every file is
|
|
50
|
+
* optional; a present-but-invalid `config.json` is a hard error (never a
|
|
51
|
+
* silent misread), with the secrets boundary enforced against `policy`. */
|
|
52
|
+
export function loadWorkspaceLayer(workspaceDir, policy) {
|
|
53
|
+
const layerRoot = join(workspaceDir, WORKSPACE_DIR_NAME);
|
|
54
|
+
const diagnostics = [];
|
|
55
|
+
const layer = { root: workspaceDir, servers: {}, tombstones: [], skills: [], diagnostics };
|
|
56
|
+
const configPath = join(layerRoot, "config.json");
|
|
57
|
+
if (existsSync(configPath)) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: invalid JSON — ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
66
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: top level must be an object { "version", "mcp" }`);
|
|
67
|
+
}
|
|
68
|
+
const { version, mcp, ...extra } = parsed;
|
|
69
|
+
const configVersion = version === undefined ? WORKSPACE_CONFIG_VERSION : version;
|
|
70
|
+
if (typeof configVersion !== "number" || !Number.isInteger(configVersion) || configVersion < 1) {
|
|
71
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: "version" must be a positive integer, got ${JSON.stringify(version)}`);
|
|
72
|
+
}
|
|
73
|
+
if (configVersion > WORKSPACE_CONFIG_VERSION) {
|
|
74
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: schema version ${configVersion} is newer than this Trellis understands (${WORKSPACE_CONFIG_VERSION}) — upgrade Trellis`);
|
|
75
|
+
}
|
|
76
|
+
for (const key of Object.keys(extra)) {
|
|
77
|
+
diagnostics.push(`${CONFIG_SOURCE_LABEL}: unrecognized top-level key "${key}" — ignored`);
|
|
78
|
+
}
|
|
79
|
+
if (mcp !== undefined) {
|
|
80
|
+
if (typeof mcp !== "object" || mcp === null || typeof mcp.servers !== "object" || mcp.servers === null || Array.isArray(mcp.servers)) {
|
|
81
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: "mcp.servers" must be an object mapping server names to definitions`);
|
|
82
|
+
}
|
|
83
|
+
for (const [name, rawDef] of Object.entries(mcp.servers)) {
|
|
84
|
+
if (typeof rawDef !== "object" || rawDef === null) {
|
|
85
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: "${name}" must be a server definition object or { "enabled": false }`);
|
|
86
|
+
}
|
|
87
|
+
if (isTombstone(rawDef)) {
|
|
88
|
+
layer.tombstones.push(name);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// enabled:false WITH a transport is tombstone semantics too — it
|
|
92
|
+
// disables the same-ID global entry and renders nothing.
|
|
93
|
+
if (rawDef.enabled === false) {
|
|
94
|
+
layer.tombstones.push(name);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
layer.servers[name] = fromServerDefYaml(name, rawDef, CONFIG_SOURCE_LABEL);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const instructionsPath = join(layerRoot, "AGENTS.md");
|
|
102
|
+
if (existsSync(instructionsPath)) {
|
|
103
|
+
layer.instructionsFile = instructionsPath;
|
|
104
|
+
}
|
|
105
|
+
const skillsRoot = join(layerRoot, "skills");
|
|
106
|
+
let skillEntries = [];
|
|
107
|
+
try {
|
|
108
|
+
skillEntries = readdirSync(skillsRoot);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// no skills/ — optional, same as every other layer file
|
|
112
|
+
}
|
|
113
|
+
for (const name of skillEntries) {
|
|
114
|
+
const dir = join(skillsRoot, name);
|
|
115
|
+
try {
|
|
116
|
+
if (statSync(dir).isDirectory())
|
|
117
|
+
layer.skills.push({ name, dir });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// unreadable/broken entry — skip, not a load failure
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
assertWorkspaceSecretsClean(layer.servers, policy);
|
|
124
|
+
return layer;
|
|
125
|
+
}
|
|
126
|
+
/** An exact `${NAME}` value is a reference to the global secret store, not
|
|
127
|
+
* a literal — the only credential-shaped content allowed in a committable
|
|
128
|
+
* project layer (same classification as mcpMigrateRead.ts). */
|
|
129
|
+
const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
130
|
+
/**
|
|
131
|
+
* Load-time secrets boundary for the project layer (workspace-scope spec
|
|
132
|
+
* "Secrets stay global-only"): scans the fields a pasted secret could
|
|
133
|
+
* reach — `env` entries, `staticEnv` values, `headers` values, and OAuth
|
|
134
|
+
* client metadata — and refuses the load, naming the server and field but
|
|
135
|
+
* never echoing the value.
|
|
136
|
+
*/
|
|
137
|
+
function assertWorkspaceSecretsClean(servers, policy) {
|
|
138
|
+
const check = (server, field, value) => {
|
|
139
|
+
if (value === undefined || VAR_REF_RE.test(value))
|
|
140
|
+
return;
|
|
141
|
+
for (const pattern of policy.rejectPatterns) {
|
|
142
|
+
if (pattern.test(value)) {
|
|
143
|
+
throw new Error(`${CONFIG_SOURCE_LABEL}: "${server}" ${field} matches a secrets.policy.yaml reject pattern — the project layer is committable and must carry only \${VAR} references; move the value to the global secret store (the offending value is intentionally not printed)`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
148
|
+
for (const entry of def.env ?? [])
|
|
149
|
+
check(name, "env", entry);
|
|
150
|
+
for (const [key, value] of Object.entries(def.staticEnv ?? {}))
|
|
151
|
+
check(name, `static_env.${key}`, value);
|
|
152
|
+
for (const [key, value] of Object.entries(def.headers ?? {}))
|
|
153
|
+
check(name, `headers.${key}`, value);
|
|
154
|
+
const meta = oauthClientMetadata(def.auth);
|
|
155
|
+
check(name, "auth.client_id", meta.clientId);
|
|
156
|
+
check(name, "auth.client_secret_env", meta.clientSecretEnv);
|
|
157
|
+
}
|
|
158
|
+
}
|