agent-trellis 0.15.1 → 0.17.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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { loadCanonicalSource } from "../core/canonical.js";
8
8
  import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
9
+ import { type VersionExecutor } from "../lib/cliEntrypoints.js";
9
10
  import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
10
11
  /**
11
12
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
@@ -15,7 +16,7 @@ import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
15
16
  * machine.
16
17
  */
17
18
  export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
18
- export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable";
19
+ export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid" | "cli-version-split";
19
20
  export interface Finding {
20
21
  kind: FindingKind;
21
22
  /** Omitted for a finding that names more than one agent (drift). */
@@ -52,6 +53,10 @@ export interface RunDoctorOptions {
52
53
  * seam. CLI discovery walks up from cwd via `findWorkspaceRoot`;
53
54
  * null = no workspace layer. */
54
55
  workspaceDir?: string | null;
56
+ /** PATH to scan for `trellis` entrypoints; defaults to the real one.
57
+ * Test seam, with `execVersion`. */
58
+ pathEnv?: string;
59
+ execVersion?: VersionExecutor;
55
60
  }
56
61
  export interface OAuthHealthEntry {
57
62
  server: string;
@@ -17,6 +17,7 @@ import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
17
17
  import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
18
18
  import { readToken } from "../lib/oauth/store.js";
19
19
  import { findWorkspaceRoot } from "../lib/workspace.js";
20
+ import { detectCliVersionSplit, findCliEntrypoints } from "../lib/cliEntrypoints.js";
20
21
  import { collectWorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
21
22
  /**
22
23
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
@@ -44,6 +45,13 @@ const EXIT_NONZERO_KINDS = new Set([
44
45
  // exists to catch *before* the session does (trellis-mcp-oauth-renew
45
46
  // design.md D4).
46
47
  "oauth-expired",
48
+ // A project-level Codex config that fails TOML parsing will fail the
49
+ // *agent*'s own startup regardless of ownership — catch it here before
50
+ // the next session does (trellis-doctor-workspace-toml-validation).
51
+ "workspace-toml-invalid",
52
+ // A stale `trellis` that wins on PATH silently changes behavior (e.g.
53
+ // rejecting config keys a newer copy accepts) — trellis-doctor-cli-version-split.
54
+ "cli-version-split",
47
55
  ]);
48
56
  /** Exported so tests can assert on this resolution directly, without
49
57
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -62,6 +70,12 @@ export function resolveKnownHostInjected(opts) {
62
70
  export async function runDoctor(opts = {}) {
63
71
  const workspaceDir = opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd());
64
72
  const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp, workspaceDir);
73
+ // CLI boundary only: spawning `trellis --version` belongs here, not in
74
+ // collectDoctorReport, so library callers and their tests never depend
75
+ // on the developer's real PATH.
76
+ const split = detectCliVersionSplit(findCliEntrypoints({ pathEnv: opts.pathEnv, execVersion: opts.execVersion }));
77
+ if (split)
78
+ report.findings.push({ kind: "cli-version-split", message: split });
65
79
  const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
66
80
  if (opts.json) {
67
81
  console.log(JSON.stringify(report, null, 2));
@@ -120,7 +134,53 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
120
134
  // canonical has not been initialized yet.
121
135
  }
122
136
  }
123
- const workspace = canonical?.workspace ? collectWorkspaceDoctorSection(canonical) : undefined;
137
+ // Workspace attribution runs whenever a workspace root is in scope —
138
+ // even when `~/.trellis/` itself failed to load or has never been
139
+ // initialized. The spec's third scenario: a user managing only project
140
+ // skills in `.agents/skills/` without a global Trellis install still
141
+ // gets project-level config validation
142
+ // (trellis-doctor-workspace-toml-validation design.md D5). When the
143
+ // canonical source is absent we synthesize the minimal workspace layer
144
+ // with every map empty — provenance is moot without a global layer, but
145
+ // `collectWorkspaceDoctorSection`'s per-file inspection is what we want.
146
+ let workspace;
147
+ if (canonical?.workspace) {
148
+ workspace = collectWorkspaceDoctorSection(canonical);
149
+ }
150
+ else {
151
+ const workspaceRoot = workspaceDir != null ? findWorkspaceRoot(workspaceDir) : null;
152
+ if (workspaceRoot) {
153
+ // `managedAgents` is driven by `~/.trellis/managed.yaml`, which is
154
+ // absent in this branch — sweep every known agent so codex's TOML
155
+ // file is still inspected (otherwise an empty list would skip the
156
+ // file entirely and the spec's scenario-3 finding would never fire).
157
+ const minimal = {
158
+ instructionsFile: "",
159
+ managedAgents: ALL_AGENTS,
160
+ skills: [],
161
+ agents: [],
162
+ memories: [],
163
+ mcp: { servers: {}, knownHostInjected: [] },
164
+ secretsPolicy: { allowedVars: [], rejectPatterns: [] },
165
+ diagnostics: [],
166
+ workspace: { root: workspaceRoot, mcpProvenance: {}, mcpGlobalServers: {}, skillProvenance: {}, globalSkills: [], shadowed: [] },
167
+ };
168
+ workspace = collectWorkspaceDoctorSection(minimal);
169
+ }
170
+ }
171
+ // Workspace TOML findings become top-level findings so the exit code
172
+ // and `--json` consumers see them alongside cross-agent findings, not
173
+ // only inside the workspace section. They keep `agent: "codex"` — the
174
+ // file layout tells us which agent the file breaks.
175
+ if (workspace) {
176
+ for (const finding of workspace.tomlFindings) {
177
+ findings.push({
178
+ kind: "workspace-toml-invalid",
179
+ agent: finding.agent,
180
+ message: `${finding.path}${finding.line !== undefined ? `:${finding.line}${finding.column !== undefined ? `:${finding.column}` : ""}` : ""} — ${finding.reason} (not rendered by Trellis; fix by hand)`,
181
+ });
182
+ }
183
+ }
124
184
  const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
125
185
  findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
126
186
  return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}), ...(workspace ? { workspace } : {}) };
@@ -395,6 +455,10 @@ export function printReport(report) {
395
455
  for (const hint of ws.unmigrated) {
396
456
  console.log(` ⚠ ${hint.path} holds ${hint.entries.length} entr${hint.entries.length === 1 ? "y" : "ies"} Trellis didn't render (${hint.entries.join(", ")}) — run \`trellis migrate --workspace\``);
397
457
  }
458
+ for (const finding of ws.tomlFindings) {
459
+ const location = finding.line !== undefined ? `:${finding.line}${finding.column !== undefined ? `:${finding.column}` : ""}` : "";
460
+ console.log(` ⚠ ${finding.path}${location} — ${finding.reason} (not rendered by Trellis — fix by hand and re-run \`trellis doctor\`)`);
461
+ }
398
462
  for (const warning of ws.precedenceWarnings) {
399
463
  console.log(` ⚠ ${warning}`);
400
464
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Locates every `trellis` executable on PATH and compares their versions
3
+ * (trellis-doctor-cli-version-split). Read-only: it never edits, removes
4
+ * or upgrades an entrypoint — a wrapper script may be deliberately
5
+ * maintained by the user, so Trellis only reports.
6
+ */
7
+ export interface CliEntrypoint {
8
+ path: string;
9
+ version: string;
10
+ }
11
+ export type VersionExecutor = (path: string) => string | undefined;
12
+ /** Runs `<path> --version` via argv (no shell). `undefined` on any
13
+ * failure or unrecognizable output, so a broken entry is skipped rather
14
+ * than reported as a split. */
15
+ export declare const defaultExecVersion: VersionExecutor;
16
+ export declare function findCliEntrypoints(opts?: {
17
+ pathEnv?: string;
18
+ execVersion?: VersionExecutor;
19
+ name?: string;
20
+ }): CliEntrypoint[];
21
+ /** A finding message when two or more entries disagree; `undefined`
22
+ * otherwise. The first entry is the one a bare `trellis` runs. */
23
+ export declare function detectCliVersionSplit(entries: readonly CliEntrypoint[]): string | undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Locates every `trellis` executable on PATH and compares their versions
3
+ * (trellis-doctor-cli-version-split). Read-only: it never edits, removes
4
+ * or upgrades an entrypoint — a wrapper script may be deliberately
5
+ * maintained by the user, so Trellis only reports.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
9
+ import { delimiter, join } from "node:path";
10
+ const VERSION_RE = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
11
+ /** Runs `<path> --version` via argv (no shell). `undefined` on any
12
+ * failure or unrecognizable output, so a broken entry is skipped rather
13
+ * than reported as a split. */
14
+ export const defaultExecVersion = (path) => {
15
+ try {
16
+ const out = execFileSync(path, ["--version"], { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] });
17
+ return VERSION_RE.exec(out)?.[0];
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ };
23
+ function isExecutableFile(path) {
24
+ try {
25
+ if (!statSync(path).isFile())
26
+ return false;
27
+ accessSync(path, constants.X_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export function findCliEntrypoints(opts = {}) {
35
+ const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
36
+ const execVersion = opts.execVersion ?? defaultExecVersion;
37
+ const name = opts.name ?? "trellis";
38
+ const seenDirs = new Set();
39
+ const seenReal = new Set();
40
+ const entries = [];
41
+ for (const dir of pathEnv.split(delimiter)) {
42
+ if (!dir || seenDirs.has(dir))
43
+ continue;
44
+ seenDirs.add(dir);
45
+ const candidate = join(dir, name);
46
+ if (!isExecutableFile(candidate))
47
+ continue;
48
+ let real;
49
+ try {
50
+ real = realpathSync(candidate);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ if (seenReal.has(real))
56
+ continue;
57
+ seenReal.add(real);
58
+ // Spawn the PATH-visible path, not the resolved target: a shim's
59
+ // behavior depends on the name it is invoked by.
60
+ const version = execVersion(candidate);
61
+ if (version)
62
+ entries.push({ path: candidate, version });
63
+ }
64
+ return entries;
65
+ }
66
+ /** A finding message when two or more entries disagree; `undefined`
67
+ * otherwise. The first entry is the one a bare `trellis` runs. */
68
+ export function detectCliVersionSplit(entries) {
69
+ if (entries.length < 2)
70
+ return undefined;
71
+ if (new Set(entries.map((e) => e.version)).size < 2)
72
+ return undefined;
73
+ const lines = entries.map((e, i) => ` ${i === 0 ? "▶ runs" : " also"} ${e.path} ${e.version}`);
74
+ return [
75
+ `${entries.length} \`trellis\` entrypoints on PATH report different versions — a bare \`trellis\` runs ${entries[0].path} (${entries[0].version}), so upgrading another copy has no effect:`,
76
+ ...lines,
77
+ "Reconcile them (upgrade or remove the stale one, or reorder PATH) — Trellis does not modify entrypoints.",
78
+ ].join("\n");
79
+ }
@@ -20,6 +20,19 @@ export interface WorkspaceUnmigratedHint {
20
20
  /** Entry names present in the file with no Trellis manifest record. */
21
21
  entries: string[];
22
22
  }
23
+ /** One parse-level failure when inspecting an agent's project MCP file.
24
+ * Distinct from `parse-diagnostic` (agent-global probes). Even when Trellis
25
+ * has never rendered into this file, a project file that breaks the agent's
26
+ * own startup is exactly what doctor exists to surface
27
+ * (trellis-doctor-workspace-toml-validation). */
28
+ export interface WorkspaceTomlFinding {
29
+ agent: AgentId;
30
+ path: string;
31
+ /** Parser's 1-based line and column of the failure, when known. */
32
+ line?: number;
33
+ column?: number;
34
+ reason: string;
35
+ }
23
36
  export interface WorkspaceDoctorSection {
24
37
  root: string;
25
38
  mcp: WorkspaceLayerEntry[];
@@ -38,6 +51,8 @@ export interface WorkspaceDoctorSection {
38
51
  name: string;
39
52
  }[];
40
53
  unmigrated: WorkspaceUnmigratedHint[];
54
+ /** Parse-level failures on workspace agent MCP files (usually empty). */
55
+ tomlFindings: WorkspaceTomlFinding[];
41
56
  /** zcode's user-level-first deviation notices (empty otherwise). */
42
57
  precedenceWarnings: string[];
43
58
  }
@@ -10,6 +10,7 @@
10
10
  * from the already-loaded canonical source and file reads only.
11
11
  */
12
12
  import { existsSync, readFileSync } from "node:fs";
13
+ import { parse as parseToml } from "smol-toml";
13
14
  import { resolveSecretEnv } from "./secretEnv.js";
14
15
  import { loadWorkspaceRendered, workspaceMcpConfigPath, workspacePrecedenceWarnings } from "./workspaceMcp.js";
15
16
  const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
@@ -36,15 +37,36 @@ function collectVarRefs(canonical) {
36
37
  return refs;
37
38
  }
38
39
  /** Entry names a JSON `mcpServers` file / Codex TOML / zcode nested
39
- * config holds beyond what the in-repo manifest records Trellis
40
- * rendering. Regex-parses Codex TOML section headers — doctor only
41
- * needs names for a hint, not a full TOML AST. */
40
+ * config holds beyond what the in-repo manifest records Trellis rendering.
41
+ *
42
+ * The Codex branch TOML-parses the file first — syntactically invalid TOML
43
+ * is indistinguishable from valid TOML to the section-header regex, but is
44
+ * fatal to Codex's own startup, so it is surfaced as `invalid` and never
45
+ * turned into an unmigrated hint. The parsed AST is discarded on success:
46
+ * header extraction keeps using the existing regex to avoid changing edge
47
+ * semantics (dotted quoted keys, single-quoted keys) that the regex was
48
+ * already validated against — smol-toml is used parse-only, never
49
+ * round-tripped, respecting lib/tomlSection.ts's write-path constraint. */
42
50
  function unmarkedEntries(agentId, configPath, recorded) {
43
51
  if (!existsSync(configPath))
44
- return [];
52
+ return { kind: "ok", names: [] };
45
53
  let names = [];
46
54
  if (agentId === "codex") {
47
55
  const content = readFileSync(configPath, "utf-8");
56
+ try {
57
+ parseToml(content);
58
+ }
59
+ catch (err) {
60
+ const tomLError = err;
61
+ const reason = tomLError.message?.split("\n")[0] ?? String(err);
62
+ return {
63
+ kind: "invalid",
64
+ path: configPath,
65
+ ...(typeof tomLError.line === "number" ? { line: tomLError.line } : {}),
66
+ ...(typeof tomLError.column === "number" ? { column: tomLError.column + 1 } : {}),
67
+ reason,
68
+ };
69
+ }
48
70
  const re = /^\[mcp_servers\.([^\]]+)\]\s*$/gm;
49
71
  let match;
50
72
  while ((match = re.exec(content)) !== null)
@@ -59,10 +81,10 @@ function unmarkedEntries(agentId, configPath, recorded) {
59
81
  names = Object.keys(servers);
60
82
  }
61
83
  catch {
62
- return []; // unparsable file is the sync guard's finding, not doctor's
84
+ return { kind: "ok", names: [] }; // unparsable file is the sync guard's finding, not doctor's
63
85
  }
64
86
  }
65
- return names.filter((name) => recorded[name] === undefined);
87
+ return { kind: "ok", names: names.filter((name) => recorded[name] === undefined) };
66
88
  }
67
89
  export function collectWorkspaceDoctorSection(canonical) {
68
90
  const workspace = canonical.workspace;
@@ -83,12 +105,17 @@ export function collectWorkspaceDoctorSection(canonical) {
83
105
  const unresolvedVars = refs.filter((r) => resolved[r.name] === undefined);
84
106
  const manifest = loadWorkspaceRendered(workspace.root);
85
107
  const unmigrated = [];
108
+ const tomlFindings = [];
86
109
  for (const agentId of canonical.managedAgents) {
87
110
  const configPath = workspaceMcpConfigPath(agentId, workspace.root);
88
- const entries = unmarkedEntries(agentId, configPath, manifest.mcp?.[agentId] ?? {});
89
- if (entries.length > 0)
90
- unmigrated.push({ agent: agentId, path: configPath, entries });
111
+ const result = unmarkedEntries(agentId, configPath, manifest.mcp?.[agentId] ?? {});
112
+ if (result.kind === "invalid") {
113
+ tomlFindings.push({ agent: agentId, path: result.path, ...(result.line !== undefined ? { line: result.line } : {}), ...(result.column !== undefined ? { column: result.column } : {}), reason: result.reason });
114
+ continue; // never report an unparseable file's entries as unmigrated — spec req 2
115
+ }
116
+ if (result.names.length > 0)
117
+ unmigrated.push({ agent: agentId, path: configPath, entries: result.names });
91
118
  }
92
119
  const precedenceWarnings = canonical.managedAgents.flatMap((id) => workspacePrecedenceWarnings(id, canonical));
93
- return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, precedenceWarnings };
120
+ return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, tomlFindings, precedenceWarnings };
94
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",
@@ -64,6 +64,7 @@
64
64
  "@modelcontextprotocol/client": "^2.3.1",
65
65
  "@modelcontextprotocol/sdk": "^1.30.0",
66
66
  "@modelcontextprotocol/server": "^2.3.1",
67
+ "smol-toml": "^1.9.1",
67
68
  "yaml": "^2.9.1",
68
69
  "zod": "^4.6.5"
69
70
  }