agent-trellis 0.21.0 → 0.22.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.
@@ -13,6 +13,8 @@ import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
13
13
  import { fromServerDefYaml, toServerDefYaml } from "./mcpServerDef.js";
14
14
  import { loadWorkspaceLayer } from "../lib/workspace.js";
15
15
  import { mergeWorkspaceLayer } from "../lib/workspaceMerge.js";
16
+ import { registeredSkillScope } from "../lib/skillScope.js";
17
+ import { isBuiltinSkillName } from "../lib/builtinSkills.js";
16
18
  export { toServerDefYaml } from "./mcpServerDef.js";
17
19
  function trellisRoot(homeDir) {
18
20
  return join(homeDir, ".trellis");
@@ -166,6 +168,9 @@ export function validateSkillScopeYaml(path) {
166
168
  const doc = parseDocument(readFileSync(path, "utf-8"));
167
169
  if (doc.errors.length > 0)
168
170
  throw new Error(doc.errors.map((error) => error.message).join("; "));
171
+ const skills = doc.get("skills", true);
172
+ if (skills !== undefined && !isMap(skills))
173
+ throw new Error("skills must be a map of names to agent lists");
169
174
  return { ok: true };
170
175
  }
171
176
  catch (err) {
@@ -174,8 +179,7 @@ export function validateSkillScopeYaml(path) {
174
179
  }
175
180
  /**
176
181
  * Sets one Skill's canonical scope without rebuilding unrelated hand-authored
177
- * scope entries. `undefined` removes this Skill's explicit entry and
178
- * therefore restores the normal all-managed default.
182
+ * scope entries. `undefined` removes registration, pausing distribution.
179
183
  */
180
184
  export function writeSkillScopeYaml(path, name, scope, backup) {
181
185
  if (!existsSync(path) && scope === undefined)
@@ -428,9 +432,6 @@ export function ensureShellEnvSource(rcPath, envFilePath, backup) {
428
432
  mkdirSync(dirname(rcPath), { recursive: true });
429
433
  persistText(rcPath, `${existing}${separator}${block}`, backup);
430
434
  }
431
- /** `undefined` if the name has no entry in scope.yaml's map — "shared with
432
- * all managed agents by default. A recognized but empty list is left as
433
- * authored (an explicitly agent-less scope), not coerced to "all". */
434
435
  /** Missing file and `agents: []` both resolve to `[]` — zero managed
435
436
  * agents, never "everyone." An unrecognized id is dropped with a
436
437
  * diagnostic, same posture as an unrecognized scope.yaml agent id. */
@@ -455,9 +456,9 @@ function scopeFor(map, name) {
455
456
  return map?.[name];
456
457
  }
457
458
  function validAgentIds(scope) {
458
- if (!scope)
459
+ if (scope === undefined)
459
460
  return true;
460
- return scope.every((id) => ALL_AGENTS.includes(id));
461
+ return Array.isArray(scope) && scope.every((id) => ALL_AGENTS.includes(id));
461
462
  }
462
463
  /**
463
464
  * Load-time literal-value scan for OAuth client metadata
@@ -497,7 +498,13 @@ export function loadCanonicalSource(arg = {}) {
497
498
  const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
498
499
  const skillDirs = listSkillDirs(join(root, "skills"));
499
500
  const knownSkillNames = new Set(skillDirs.map((s) => s.name));
500
- const skills = skillDirs.map(({ name, dir }) => ({ name, dir, scope: scopeFor(scopeYaml.skills, name) }));
501
+ const skills = skillDirs.map(({ name, dir }) => {
502
+ const declared = scopeFor(scopeYaml.skills, name);
503
+ if (declared === undefined && !isBuiltinSkillName(name)) {
504
+ diagnostics.push(`scope.yaml: skill "${name}" has no registered agents — distribution paused; run trellis skill scope ${name} --agents <ids>`);
505
+ }
506
+ return { name, dir, scope: isBuiltinSkillName(name) && declared === undefined ? undefined : registeredSkillScope(declared) };
507
+ });
501
508
  const agentFiles = listMarkdownFiles(join(root, "agents"));
502
509
  const knownAgentProfileNames = new Set(agentFiles.map((a) => a.name));
503
510
  const agents = agentFiles.map(({ name, file }) => ({
@@ -523,7 +530,7 @@ export function loadCanonicalSource(arg = {}) {
523
530
  diagnostics.push(`scope.yaml: "${section}.${name}" does not match any known ${section.slice(0, -1)} — ignored`);
524
531
  }
525
532
  else if (!validAgentIds(map?.[name])) {
526
- diagnostics.push(`scope.yaml: "${section}.${name}" lists an unrecognized agent id — ignored`);
533
+ diagnostics.push(`scope.yaml: "${section}.${name}" lists an unrecognized agent id${section === "skills" ? " — distribution paused" : " — ignored"}`);
527
534
  }
528
535
  }
529
536
  }
@@ -39,11 +39,9 @@ export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig |
39
39
  export type AgentId = "claude-code" | "codex" | "kiro" | "pi" | "kimi-code" | "zcode";
40
40
  export declare const ALL_AGENTS: readonly AgentId[];
41
41
  /**
42
- * Every scopable item (skill, subagent, memory entry, MCP server) defaults
43
- * to all managed agents when `scope` is omitted — sharing everywhere is the
44
- * common case Trellis exists for; restricting to specific agents is the
45
- * exception and must be declared explicitly. See docs/architecture.md
46
- * "Private / agent-specific capabilities".
42
+ * Omitted scope resolves to the managed set. Custom skills are registered
43
+ * explicitly by the canonical/workspace loaders: missing or invalid skill
44
+ * registration becomes `[]` (paused). Other capability defaults are unchanged.
47
45
  */
48
46
  export type Scope = AgentId[] | undefined;
49
47
  /**
@@ -69,7 +67,9 @@ export interface McpServerDef {
69
67
  /** http/sse only */
70
68
  url?: string;
71
69
  /**
72
- * http/sse only. Values are `${VAR}` references, same discipline as
70
+ * http/sse only. Values are `${VAR}` or `${mxenv://profile/KEY}` templates,
71
+ * the latter requiring Trellis-hosted resolution (gateway/runtime/pi).
72
+ * Same discipline as
73
73
  * `env` — never a real value. Codex has no generic headers concept;
74
74
  * only the single shape `{ Authorization: "Bearer ${VAR}" }` is
75
75
  * expressible there (trellis-mcp-transport-auth design.md D4) — any
@@ -190,7 +190,8 @@ export interface SkillRef {
190
190
  * filename is case-sensitive on at least one target agent (Codex). */
191
191
  dir: string;
192
192
  /**
193
- * Omit for "all agents" (the default). Deliberately NOT read from
193
+ * Explicit applicable agents; unregistered custom skills load as `[]`.
194
+ * Package-owned built-ins may supply their own default. NOT read from
194
195
  * SKILL.md's own frontmatter — Codex validates SKILL.md frontmatter
195
196
  * against an allow-list of known keys and rejects unknown ones (see
196
197
  * docs/research.md), so a Trellis-only field embedded there would break
@@ -7,7 +7,11 @@
7
7
  * still more machinery than this one narrow extraction needs.
8
8
  */
9
9
  import type { McpServerDef } from "../core/types.js";
10
- /** Every `${NAME}` occurrence in a string — a header value can in
10
+ export declare function replaceTemplateVars(value: string, replace: (entry: string) => string): string;
11
+ /** Report only the header's name, never a potentially sensitive template. */
12
+ export declare function invalidHeaderTemplate(headers: Record<string, string> | undefined): string | undefined;
13
+ /** Every `${NAME}` or `${mxenv://profile/KEY}` occurrence in a string,
14
+ * including malformed placeholders for validation — a header value can in
11
15
  * principle reference more than one name (trellis-mcp-transport-auth
12
16
  * design.md D6), same extraction `env`'s single-name-per-value case
13
17
  * already needed, just generalized. */
@@ -6,8 +6,21 @@
6
6
  * there is no round-trip-fidelity concern here, but a full parser is
7
7
  * still more machinery than this one narrow extraction needs.
8
8
  */
9
- const TEMPLATE_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
10
- /** Every `${NAME}` occurrence in a string — a header value can in
9
+ // Keep malformed placeholders visible too: a misspelled reference scheme
10
+ // must reach validation rather than silently survive as literal header text.
11
+ const TEMPLATE_VAR_RE = /\$\{([^{}]*)\}/g;
12
+ export function replaceTemplateVars(value, replace) {
13
+ return value.replace(TEMPLATE_VAR_RE, (_match, entry) => replace(entry));
14
+ }
15
+ /** Report only the header's name, never a potentially sensitive template. */
16
+ export function invalidHeaderTemplate(headers) {
17
+ return Object.entries(headers ?? {}).find(([, value]) => {
18
+ const literal = replaceTemplateVars(value, () => "");
19
+ return literal.includes("${") || /mx-?env:\/\//.test(literal);
20
+ })?.[0];
21
+ }
22
+ /** Every `${NAME}` or `${mxenv://profile/KEY}` occurrence in a string,
23
+ * including malformed placeholders for validation — a header value can in
11
24
  * principle reference more than one name (trellis-mcp-transport-auth
12
25
  * design.md D6), same extraction `env`'s single-name-per-value case
13
26
  * already needed, just generalized. */
@@ -14,8 +14,8 @@
14
14
  */
15
15
  import { Client, StreamableHTTPClientTransport, SSEClientTransport } from "@modelcontextprotocol/client";
16
16
  import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
17
- import { deliveredName, resolveSecretEnv } from "./secretEnv.js";
18
- import { extractTemplateVarNames } from "./envVarNames.js";
17
+ import { deliveredName, isValidSecretVarName, parseEnvEntry, resolveSecretEntries, resolveSecretEnv } from "./secretEnv.js";
18
+ import { extractTemplateVarNames, invalidHeaderTemplate, replaceTemplateVars } from "./envVarNames.js";
19
19
  export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
20
20
  const FAILED_TRANSPORT_CLEANUP_TIMEOUT_MS = 1_000;
21
21
  /**
@@ -105,11 +105,25 @@ export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
105
105
  export async function resolveHeaders(def, secretsPolicy) {
106
106
  if (!def.headers || Object.keys(def.headers).length === 0)
107
107
  return undefined;
108
+ const invalidHeader = invalidHeaderTemplate(def.headers);
109
+ if (invalidHeader !== undefined)
110
+ throw new Error(`[trellis] invalid reference template in header ${invalidHeader} — use \${VAR} or \${mxenv://<profile>/<KEY>}`);
108
111
  const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
109
- const resolved = await resolveSecretEnv(names, secretsPolicy);
112
+ for (const name of names) {
113
+ const entry = parseEnvEntry(name);
114
+ if (entry.kind === "invalid" || (entry.kind === "name" && !isValidSecretVarName(name))) {
115
+ throw new Error(`[trellis] invalid header reference: ${name} — expected a variable name or mxenv://<profile>/<KEY>`);
116
+ }
117
+ }
118
+ const resolved = await resolveSecretEntries(names, secretsPolicy);
110
119
  const result = {};
111
120
  for (const [key, template] of Object.entries(def.headers)) {
112
- result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
121
+ result[key] = replaceTemplateVars(template, (name) => {
122
+ if (parseEnvEntry(name).kind === "ref" && !resolved[name]) {
123
+ throw new Error(`[trellis] unresolved header reference: ${name}`);
124
+ }
125
+ return resolved[name] ?? "";
126
+ });
113
127
  }
114
128
  return result;
115
129
  }
@@ -50,9 +50,6 @@ export type SecretEnvEntry = {
50
50
  export declare function deliveredName(entry: string): string;
51
51
  export declare function parseEnvEntry(entry: string): SecretEnvEntry;
52
52
  /**
53
- * Result keyed by DELIVERED name: the `KEY` segment for a reference,
54
- * the entry itself for a bare name (mx-env-secret-source spec).
55
- *
56
53
  * `policy.envFile` set: it is the SOLE source for bare names — a name
57
54
  * absent from it resolves to `undefined`, `process.env` is never
58
55
  * consulted (a silent fallback would defeat the isolation this exists
@@ -66,7 +63,14 @@ export declare function parseEnvEntry(entry: string): SecretEnvEntry;
66
63
  * before this point (the bridge only ever sees pre-checked defs).
67
64
  * Compositions are cached per batch (one `ReferenceResolver` per call,
68
65
  * not per name).
69
- */
66
+ * Raw-entry keys preserve provenance when headers mix two profiles with
67
+ * the same KEY, or a reference and a bare name. Values stay in memory. */
68
+ export declare function resolveSecretEntries(entries: string[], policy: SecretsPolicy): Promise<Record<string, string | undefined>>;
69
+ export declare class SecretReferenceError extends Error {
70
+ readonly entry: string;
71
+ constructor(entry: string);
72
+ }
73
+ /** Compatibility view keyed by delivered name (KEY for a reference). */
70
74
  export declare function resolveSecretEnv(entries: string[], policy: SecretsPolicy): Promise<Record<string, string | undefined>>;
71
75
  export type WriteLocalSecretOutcome = "created" | "already-present" | "conflict";
72
76
  /**
@@ -51,7 +51,7 @@ export function deliveredName(entry) {
51
51
  return parsed.kind === "invalid" ? entry : parsed.delivered;
52
52
  }
53
53
  export function parseEnvEntry(entry) {
54
- if (entry.includes("mxenv://") || entry.includes("mx-env://")) {
54
+ if (entry.includes("://")) {
55
55
  try {
56
56
  const refs = findMxEnvReferences(entry);
57
57
  if (refs.length === 1 && `mxenv://${refs[0].profile}/${refs[0].key}` === entry) {
@@ -66,9 +66,6 @@ export function parseEnvEntry(entry) {
66
66
  return { kind: "name", delivered: entry };
67
67
  }
68
68
  /**
69
- * Result keyed by DELIVERED name: the `KEY` segment for a reference,
70
- * the entry itself for a bare name (mx-env-secret-source spec).
71
- *
72
69
  * `policy.envFile` set: it is the SOLE source for bare names — a name
73
70
  * absent from it resolves to `undefined`, `process.env` is never
74
71
  * consulted (a silent fallback would defeat the isolation this exists
@@ -82,27 +79,38 @@ export function parseEnvEntry(entry) {
82
79
  * before this point (the bridge only ever sees pre-checked defs).
83
80
  * Compositions are cached per batch (one `ReferenceResolver` per call,
84
81
  * not per name).
85
- */
86
- export async function resolveSecretEnv(entries, policy) {
82
+ * Raw-entry keys preserve provenance when headers mix two profiles with
83
+ * the same KEY, or a reference and a bare name. Values stay in memory. */
84
+ export async function resolveSecretEntries(entries, policy) {
87
85
  const parsed = entries.map(parseEnvEntry);
88
86
  const invalid = parsed.find((p) => p.kind === "invalid");
89
87
  if (invalid) {
90
88
  throw new Error(`[trellis] invalid mxenv:// reference entry: ${invalid.entry} — expected mxenv://<profile>/<KEY>`);
91
89
  }
92
90
  const result = {};
93
- // Delivered-name collisions (several entries delivering one name):
94
- // references are authoritative — a composed value never loses to a bare
95
- // name's ambient/file value (ambient must not shadow references); a
96
- // DEFINED value never loses to undefined; within one class the first
97
- // declaration wins; bare names only fill keys nothing has filled.
91
+ // Cache compositions once per batch; raw keys keep profiles distinct.
98
92
  const refs = parsed.filter((p) => p.kind === "ref");
99
93
  if (refs.length > 0) {
100
- const resolver = new ReferenceResolver(loadConfigLayers({ cwd: homedir(), homeDir: homedir() }), {});
94
+ let resolver;
95
+ try {
96
+ resolver = new ReferenceResolver(loadConfigLayers({ cwd: homedir(), homeDir: homedir() }), {});
97
+ }
98
+ catch {
99
+ throw new SecretReferenceError(entries[parsed.indexOf(refs[0])]);
100
+ }
101
101
  for (const ref of refs) {
102
- if (result[ref.delivered] !== undefined)
102
+ const entry = `mxenv://${ref.profile}/${ref.key}`;
103
+ if (Object.hasOwn(result, entry))
103
104
  continue;
104
- const { value } = await resolver.valueOf(ref.profile, ref.key);
105
- result[ref.delivered] = value;
105
+ try {
106
+ const { value } = await resolver.valueOf(ref.profile, ref.key);
107
+ result[entry] = value;
108
+ }
109
+ catch {
110
+ // Provider errors may contain credential material. Report only the
111
+ // reference, never propagate provider output to logs or audit.
112
+ throw new SecretReferenceError(entry);
113
+ }
106
114
  }
107
115
  }
108
116
  const names = parsed.filter((p) => p.kind === "name");
@@ -116,6 +124,27 @@ export async function resolveSecretEnv(entries, policy) {
116
124
  }
117
125
  return result;
118
126
  }
127
+ export class SecretReferenceError extends Error {
128
+ entry;
129
+ constructor(entry) {
130
+ super(`[trellis] could not resolve mxenv reference ${entry} — check the profile, KEY and provider configuration`);
131
+ this.entry = entry;
132
+ }
133
+ }
134
+ /** Compatibility view keyed by delivered name (KEY for a reference). */
135
+ export async function resolveSecretEnv(entries, policy) {
136
+ const values = await resolveSecretEntries(entries, policy);
137
+ const result = {};
138
+ // Preserve the existing delivered-name collision rules for env/envAliases.
139
+ const ordered = [...entries.filter((entry) => parseEnvEntry(entry).kind === "ref"),
140
+ ...entries.filter((entry) => parseEnvEntry(entry).kind !== "ref")];
141
+ for (const entry of ordered) {
142
+ const name = deliveredName(entry);
143
+ if (result[name] === undefined)
144
+ result[name] = values[entry];
145
+ }
146
+ return result;
147
+ }
119
148
  /**
120
149
  * `trellis migrate`'s static-env secret extraction
121
150
  * (trellis-migrate-extract-static-env-secrets design.md D4) — appends
@@ -0,0 +1,14 @@
1
+ import { type AgentId } from "../core/types.js";
2
+ import { type ScopeSelectorRaw } from "./scopeSelection.js";
3
+ export declare function registeredSkillScope(value: unknown): AgentId[];
4
+ export declare function readSkillScopes(path: string): Record<string, unknown>;
5
+ export declare function selectSkillScope(raw: ScopeSelectorRaw, managed: readonly AgentId[]): {
6
+ scope: AgentId[];
7
+ } | {
8
+ error: string;
9
+ };
10
+ export declare function requestSkillScope(raw: ScopeSelectorRaw, managed: readonly AgentId[], interactive: boolean): Promise<{
11
+ scope: AgentId[];
12
+ } | {
13
+ error: string;
14
+ }>;
@@ -0,0 +1,40 @@
1
+ /** Skill scope is a registry, not an exceptions list. Unregistered or
2
+ * invalid entries stay paused until applicable agents are selected. */
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { ALL_AGENTS } from "../core/types.js";
6
+ import { resolveScopeSelection } from "./scopeSelection.js";
7
+ import { canUseInteractivePicker, runMultiSelectPicker } from "./terminalPicker.js";
8
+ export function registeredSkillScope(value) {
9
+ return Array.isArray(value) && value.every((id) => ALL_AGENTS.includes(id)) ? [...new Set(value)] : [];
10
+ }
11
+ export function readSkillScopes(path) {
12
+ if (!existsSync(path))
13
+ return {};
14
+ const doc = parseYaml(readFileSync(path, "utf8"));
15
+ if (doc?.skills === undefined)
16
+ return {};
17
+ if (typeof doc.skills !== "object" || doc.skills === null || Array.isArray(doc.skills)) {
18
+ throw new Error(`${path}: skills must be a map of skill names to agent lists`);
19
+ }
20
+ return doc.skills;
21
+ }
22
+ export function selectSkillScope(raw, managed) {
23
+ const selected = resolveScopeSelection(raw, managed);
24
+ if (!selected.ok)
25
+ return { error: selected.detail };
26
+ // Even "all" records today's set. Managing another agent later must
27
+ // never expand the compatibility decision made for an existing skill.
28
+ return { scope: selected.scope ?? [...managed] };
29
+ }
30
+ export async function requestSkillScope(raw, managed, interactive) {
31
+ if (raw.agents !== undefined || raw.all || raw.none)
32
+ return selectSkillScope(raw, managed);
33
+ if (!interactive || !canUseInteractivePicker()) {
34
+ return { error: "choose applicable agents with --agents <ids>, --all or --none; no scope is selected automatically" };
35
+ }
36
+ if (managed.length === 0)
37
+ return { error: "no managed agents — run trellis onboard, or use --none to import a paused skill" };
38
+ const indices = await runMultiSelectPicker([...managed], managed.map(() => false), undefined, "选择这个 skill 适用的 agent(不选则暂停分发)");
39
+ return indices === null ? { error: "skill import cancelled" } : { scope: indices.map((index) => managed[index]) };
40
+ }
@@ -12,6 +12,7 @@ import { homedir } from "node:os";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { oauthClientMetadata } from "../core/types.js";
14
14
  import { fromServerDefYaml } from "../core/mcpServerDef.js";
15
+ import { readSkillScopes, registeredSkillScope } from "./skillScope.js";
15
16
  export const WORKSPACE_DIR_NAME = ".agents";
16
17
  /** The only `config.json` schema version this implementation understands. */
17
18
  export const WORKSPACE_CONFIG_VERSION = 1;
@@ -103,6 +104,7 @@ export function loadWorkspaceLayer(workspaceDir, policy) {
103
104
  layer.instructionsFile = instructionsPath;
104
105
  }
105
106
  const skillsRoot = join(layerRoot, "skills");
107
+ const scopes = readSkillScopes(join(layerRoot, "scope.yaml"));
106
108
  let skillEntries = [];
107
109
  try {
108
110
  skillEntries = readdirSync(skillsRoot);
@@ -113,8 +115,11 @@ export function loadWorkspaceLayer(workspaceDir, policy) {
113
115
  for (const name of skillEntries) {
114
116
  const dir = join(skillsRoot, name);
115
117
  try {
116
- if (statSync(dir).isDirectory())
117
- layer.skills.push({ name, dir });
118
+ if (statSync(dir).isDirectory()) {
119
+ layer.skills.push({ name, dir, scope: registeredSkillScope(scopes[name]) });
120
+ if (!Object.hasOwn(scopes, name))
121
+ diagnostics.push(`.agents/scope.yaml: skill "${name}" has no registered agents — Trellis distribution paused`);
122
+ }
118
123
  }
119
124
  catch {
120
125
  // unreadable/broken entry — skip, not a load failure