agent-trellis 0.2.0 → 0.3.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/README.md +12 -0
- package/dist/adapters/claude-code.d.ts +3 -2
- package/dist/adapters/claude-code.js +21 -8
- package/dist/adapters/codex.d.ts +6 -3
- package/dist/adapters/codex.js +41 -10
- package/dist/adapters/jsonMcp.d.ts +15 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +3 -2
- package/dist/adapters/kiro.js +22 -9
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +39 -6
- package/dist/cli.js +123 -10
- package/dist/commands/mcp.d.ts +101 -7
- package/dist/commands/mcp.js +227 -10
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +18 -8
- package/dist/commands/onboard.js +124 -14
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/core/adapter.d.ts +18 -8
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +81 -3
- package/dist/core/types.d.ts +17 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +76 -46
- package/dist/pi-bridge/index.js +7 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +166 -10
- package/docs/roadmap.md +311 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
package/dist/core/canonical.d.ts
CHANGED
|
@@ -4,7 +4,31 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import type { CanonicalSource } from "./types.js";
|
|
7
|
+
import type { CanonicalSource, McpServerDef } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* The on-disk shape for one server entry — `static_env` (snake_case, like
|
|
10
|
+
* every other multi-word key across `.trellis/*.yaml`) is translated to
|
|
11
|
+
* `McpServerDef.staticEnv` (camelCase) below; every other field happens
|
|
12
|
+
* to already be a single word, so no server-def field needed this
|
|
13
|
+
* treatment before (trellis-mcp-static-env-and-disabled-servers).
|
|
14
|
+
*/
|
|
15
|
+
type McpServerDefYaml = Omit<McpServerDef, "staticEnv"> & {
|
|
16
|
+
static_env?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
19
|
+
* `undefined` fields so the written YAML never gets a literal `null`
|
|
20
|
+
* for an omitted optional. */
|
|
21
|
+
export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
|
|
22
|
+
export type ServersYamlWriteResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
error: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function upsertServerYaml(path: string, name: string, def: McpServerDef): ServersYamlWriteResult;
|
|
29
|
+
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
30
|
+
* refusal posture on a missing/unparseable file. */
|
|
31
|
+
export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
|
|
8
32
|
/**
|
|
9
33
|
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
10
34
|
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
|
@@ -14,3 +38,4 @@ import type { CanonicalSource } from "./types.js";
|
|
|
14
38
|
* parameter does not weaken.
|
|
15
39
|
*/
|
|
16
40
|
export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
|
|
41
|
+
export {};
|
package/dist/core/canonical.js
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { basename, join } from "node:path";
|
|
10
|
-
import { parse as parseYaml } from "yaml";
|
|
10
|
+
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
11
11
|
import { ALL_AGENTS } from "./types.js";
|
|
12
|
+
function fromServerDefYaml(def) {
|
|
13
|
+
const { static_env, ...rest } = def;
|
|
14
|
+
return static_env ? { ...rest, staticEnv: static_env } : rest;
|
|
15
|
+
}
|
|
16
|
+
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
17
|
+
* `undefined` fields so the written YAML never gets a literal `null`
|
|
18
|
+
* for an omitted optional. */
|
|
19
|
+
export function toServerDefYaml(def) {
|
|
20
|
+
const { staticEnv, ...rest } = def;
|
|
21
|
+
const out = { ...rest };
|
|
22
|
+
if (staticEnv)
|
|
23
|
+
out.static_env = staticEnv;
|
|
24
|
+
for (const key of Object.keys(out)) {
|
|
25
|
+
if (out[key] === undefined)
|
|
26
|
+
delete out[key];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
12
30
|
function trellisRoot(homeDir) {
|
|
13
31
|
return join(homeDir, ".trellis");
|
|
14
32
|
}
|
|
@@ -58,12 +76,72 @@ function loadServersYaml(path) {
|
|
|
58
76
|
return { servers: {}, knownHostInjected: [] };
|
|
59
77
|
}
|
|
60
78
|
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
79
|
+
const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(def)]));
|
|
61
80
|
return {
|
|
62
|
-
servers
|
|
81
|
+
servers,
|
|
63
82
|
knownHostInjected: parsed.known_host_injected ?? [],
|
|
64
83
|
hub: parsed.hub,
|
|
65
84
|
};
|
|
66
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Writes or replaces one server entry in `servers.yaml`, preserving
|
|
88
|
+
* every other entry's and comment's exact formatting (trellis-
|
|
89
|
+
* canonical-cli-crud design.md D3) — a `Document`-based edit
|
|
90
|
+
* (`setIn`), never `parse` + rebuild + `stringify`, which would
|
|
91
|
+
* re-serialize the whole file and lose anything hand-authored outside
|
|
92
|
+
* the touched entry. Refuses (no write) if the file doesn't exist yet
|
|
93
|
+
* (run `trellis init` first) or fails to parse.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* `trellis init`'s starter `servers.yaml` writes `servers: {}` — an empty
|
|
97
|
+
* flow-style map, since there's nothing to indent yet. `Document#setIn`
|
|
98
|
+
* inserting into an *existing* flow map keeps rendering it as flow, so
|
|
99
|
+
* the first `mcp add`/`migrate --only mcp` onto a fresh file — and every
|
|
100
|
+
* one after it — would render as one unreadable line (found via actually
|
|
101
|
+
* running the CLI against a fresh `init`, not by inspection). Forcing the
|
|
102
|
+
* touched map, and the newly-set entry's own map, to block style is a
|
|
103
|
+
* one-line, local fix — it never touches any sibling entry's own style,
|
|
104
|
+
* so a file someone deliberately kept flow-style elsewhere is untouched.
|
|
105
|
+
*/
|
|
106
|
+
function forceBlockStyle(node) {
|
|
107
|
+
if (isMap(node)) {
|
|
108
|
+
node.flow = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function upsertServerYaml(path, name, def) {
|
|
112
|
+
if (!existsSync(path)) {
|
|
113
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
114
|
+
}
|
|
115
|
+
let doc;
|
|
116
|
+
try {
|
|
117
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
121
|
+
}
|
|
122
|
+
doc.setIn(["servers", name], toServerDefYaml(def));
|
|
123
|
+
forceBlockStyle(doc.get("servers", true));
|
|
124
|
+
forceBlockStyle(doc.getIn(["servers", name], true));
|
|
125
|
+
writeFileSync(path, doc.toString());
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
128
|
+
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
129
|
+
* refusal posture on a missing/unparseable file. */
|
|
130
|
+
export function removeServerYaml(path, name) {
|
|
131
|
+
if (!existsSync(path)) {
|
|
132
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
133
|
+
}
|
|
134
|
+
let doc;
|
|
135
|
+
try {
|
|
136
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
140
|
+
}
|
|
141
|
+
doc.deleteIn(["servers", name]);
|
|
142
|
+
writeFileSync(path, doc.toString());
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
67
145
|
function loadSecretsPolicyYaml(path, homeDir) {
|
|
68
146
|
if (!existsSync(path)) {
|
|
69
147
|
return { allowedVars: [], rejectPatterns: [] };
|
package/dist/core/types.d.ts
CHANGED
|
@@ -44,6 +44,23 @@ export interface McpServerDef {
|
|
|
44
44
|
* docs/research.md "Secrets" and schema/secrets.policy.example.yaml.
|
|
45
45
|
*/
|
|
46
46
|
env?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Literal, non-secret values written into the agent's config verbatim
|
|
49
|
+
* — distinct from `env`'s names-only, resolved-at-runtime contract.
|
|
50
|
+
* Still scanned against `reject_patterns` like every other literal
|
|
51
|
+
* field (trellis-mcp-static-env-and-disabled-servers design.md D2):
|
|
52
|
+
* this is for values that were never secrets (an email address, an
|
|
53
|
+
* environment tag), not an escape hatch for real credentials.
|
|
54
|
+
*/
|
|
55
|
+
staticEnv?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Defaults to `true`. `false` keeps the definition in canonical
|
|
58
|
+
* without writing it to any agent — matches a real host config's own
|
|
59
|
+
* "defined but currently off" state (e.g. Codex's `enabled = false`)
|
|
60
|
+
* that omitting the definition entirely can't represent, since that
|
|
61
|
+
* would also throw away the definition itself.
|
|
62
|
+
*/
|
|
63
|
+
enabled?: boolean;
|
|
47
64
|
/** Omit for "all agents" (the default). See `Scope`. */
|
|
48
65
|
agents?: Scope;
|
|
49
66
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural equality for plain JSON-shaped values — shared by every
|
|
3
|
+
* "does this already match what Trellis would write/has written"
|
|
4
|
+
* comparison (jsonMcp.ts's create/remove decisions, migrate-in's
|
|
5
|
+
* conflict detection) so they can't silently drift apart into two
|
|
6
|
+
* different notions of "equal."
|
|
7
|
+
*/
|
|
8
|
+
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural equality for plain JSON-shaped values — shared by every
|
|
3
|
+
* "does this already match what Trellis would write/has written"
|
|
4
|
+
* comparison (jsonMcp.ts's create/remove decisions, migrate-in's
|
|
5
|
+
* conflict detection) so they can't silently drift apart into two
|
|
6
|
+
* different notions of "equal."
|
|
7
|
+
*/
|
|
8
|
+
export function deepEqual(a, b) {
|
|
9
|
+
if (a === b)
|
|
10
|
+
return true;
|
|
11
|
+
if (a === null || b === null || typeof a !== typeof b)
|
|
12
|
+
return false;
|
|
13
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
14
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
15
|
+
return false;
|
|
16
|
+
return a.every((value, index) => deepEqual(value, b[index]));
|
|
17
|
+
}
|
|
18
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
19
|
+
const aKeys = Object.keys(a);
|
|
20
|
+
const bKeys = Object.keys(b);
|
|
21
|
+
if (aKeys.length !== bKeys.length)
|
|
22
|
+
return false;
|
|
23
|
+
return aKeys.every((key) => deepEqual(a[key], b[key]));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
package/dist/lib/dirEquals.d.ts
CHANGED
|
@@ -5,3 +5,12 @@
|
|
|
5
5
|
* paths, same bytes per file; anything else is unequal.
|
|
6
6
|
*/
|
|
7
7
|
export declare function dirContentsEqual(a: string, b: string): boolean;
|
|
8
|
+
export type DirImportDecision = "create" | "already-present" | "conflict";
|
|
9
|
+
/**
|
|
10
|
+
* Shared create/already-present/conflict decision for copying a real
|
|
11
|
+
* directory into a canonical destination — used by both `migrate`'s
|
|
12
|
+
* own skill planning and `trellis skill add` (trellis-canonical-cli-crud
|
|
13
|
+
* design.md D2), so the two never silently drift apart on what counts
|
|
14
|
+
* as "safe to skip" vs. "a real conflict."
|
|
15
|
+
*/
|
|
16
|
+
export declare function decideDirImport(sourceDir: string, canonicalDir: string): DirImportDecision;
|
package/dist/lib/dirEquals.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* by this, not by name/mtime/hash-shortcut. Same set of relative file
|
|
5
5
|
* paths, same bytes per file; anything else is unequal.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
8
|
import { join, relative } from "node:path";
|
|
9
9
|
function listFilesRecursive(dir) {
|
|
10
10
|
const results = [];
|
|
@@ -37,3 +37,17 @@ export function dirContentsEqual(a, b) {
|
|
|
37
37
|
}
|
|
38
38
|
return true;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Shared create/already-present/conflict decision for copying a real
|
|
42
|
+
* directory into a canonical destination — used by both `migrate`'s
|
|
43
|
+
* own skill planning and `trellis skill add` (trellis-canonical-cli-crud
|
|
44
|
+
* design.md D2), so the two never silently drift apart on what counts
|
|
45
|
+
* as "safe to skip" vs. "a real conflict."
|
|
46
|
+
*/
|
|
47
|
+
export function decideDirImport(sourceDir, canonicalDir) {
|
|
48
|
+
if (!existsSync(canonicalDir))
|
|
49
|
+
return "create";
|
|
50
|
+
if (dirContentsEqual(sourceDir, canonicalDir))
|
|
51
|
+
return "already-present";
|
|
52
|
+
return "conflict";
|
|
53
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an agent's own real, already-configured MCP servers and converts
|
|
3
|
+
* each into canonical's `McpServerDef` shape — `trellis migrate --only
|
|
4
|
+
* mcp`'s read path (trellis-migrate-mcp-servers). Deliberately separate
|
|
5
|
+
* from each probe's own `AgentSnapshotMcpServer` (src/probes/*.ts) and its
|
|
6
|
+
* thin `{name, transport, probe?}` shape used by `doctor` — that shape
|
|
7
|
+
* discards exactly the fields this module needs, and extending it would
|
|
8
|
+
* risk `doctor`'s existing, extensively-tested behavior for no reason
|
|
9
|
+
* (design.md D1). pi has no static MCP config to read at all — no reader
|
|
10
|
+
* is defined for it; `migrate.ts` skips pi for the `mcp` category
|
|
11
|
+
* entirely, the same fact already established for skills/instructions.
|
|
12
|
+
*/
|
|
13
|
+
import type { McpServerDef } from "../core/types.js";
|
|
14
|
+
export interface McpMigrateEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
def: McpServerDef;
|
|
17
|
+
}
|
|
18
|
+
export interface McpMigrateUnsupported {
|
|
19
|
+
name: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
export interface McpMigrateReadResult {
|
|
23
|
+
entries: McpMigrateEntry[];
|
|
24
|
+
unsupported: McpMigrateUnsupported[];
|
|
25
|
+
}
|
|
26
|
+
export declare function readClaudeCodeMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
27
|
+
export declare function readKiroMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
28
|
+
export interface CodexMcpEntryRich {
|
|
29
|
+
name: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
transport: {
|
|
32
|
+
type: string;
|
|
33
|
+
command?: string;
|
|
34
|
+
args?: string[];
|
|
35
|
+
env_vars?: string[];
|
|
36
|
+
/** Remote-transport fields. Confirmed by running the real,
|
|
37
|
+
* locally-installed `codex-cli 0.154.0` against a hand-written
|
|
38
|
+
* `url`-only server and a `url` + `bearer_token_env_var` server: the
|
|
39
|
+
* type string is `"streamable_http"` (not `"http"`), and the three
|
|
40
|
+
* `*headers*` fields come back `null` when unused. Codex's own
|
|
41
|
+
* config.toml schema (`tomlSection.ts`'s `renderServerSection`) has
|
|
42
|
+
* no way to distinguish `http` from `sse` at all — both render
|
|
43
|
+
* identically — so there is no lossy guess in always reading a
|
|
44
|
+
* remote entry back as `"http"`; that distinction was never stored. */
|
|
45
|
+
url?: string;
|
|
46
|
+
bearer_token_env_var?: string | null;
|
|
47
|
+
http_headers?: unknown;
|
|
48
|
+
env_http_headers?: unknown;
|
|
49
|
+
http_headers_helper?: unknown;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pure conversion half — separated from the subprocess/file-read effects
|
|
54
|
+
* below so the stdio/non-stdio/malformed decision logic is unit-testable
|
|
55
|
+
* without a real `codex` binary on PATH (this codebase has no fake-codex
|
|
56
|
+
* fixture anywhere; codex's subprocess dependency is otherwise entirely
|
|
57
|
+
* untested at the unit level).
|
|
58
|
+
*/
|
|
59
|
+
export declare function buildCodexMcpReadResult(mcpEntries: CodexMcpEntryRich[], tomlContent: string | undefined): McpMigrateReadResult;
|
|
60
|
+
/**
|
|
61
|
+
* Stdio only (design.md D2) — `codex mcp list --json`'s own output never
|
|
62
|
+
* exposes `url`/`bearer_token_env_var` in this codebase's `CodexMcpEntry`
|
|
63
|
+
* (src/probes/codex.ts), and this codebase has no verified evidence for
|
|
64
|
+
* what that subprocess reports for a non-stdio server. Guessing at an
|
|
65
|
+
* external tool's unverified output shape is exactly what this project's
|
|
66
|
+
* "verify, don't assume" discipline exists to prevent — a non-stdio
|
|
67
|
+
* entry is reported as unsupported instead.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readCodexMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an agent's own real, already-configured MCP servers and converts
|
|
3
|
+
* each into canonical's `McpServerDef` shape — `trellis migrate --only
|
|
4
|
+
* mcp`'s read path (trellis-migrate-mcp-servers). Deliberately separate
|
|
5
|
+
* from each probe's own `AgentSnapshotMcpServer` (src/probes/*.ts) and its
|
|
6
|
+
* thin `{name, transport, probe?}` shape used by `doctor` — that shape
|
|
7
|
+
* discards exactly the fields this module needs, and extending it would
|
|
8
|
+
* risk `doctor`'s existing, extensively-tested behavior for no reason
|
|
9
|
+
* (design.md D1). pi has no static MCP config to read at all — no reader
|
|
10
|
+
* is defined for it; `migrate.ts` skips pi for the `mcp` category
|
|
11
|
+
* entirely, the same fact already established for skills/instructions.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { readJsonFile } from "./probeCommon.js";
|
|
17
|
+
import { readServerEnvTable } from "./tomlSection.js";
|
|
18
|
+
const EMPTY_RESULT = { entries: [], unsupported: [] };
|
|
19
|
+
/**
|
|
20
|
+
* Trellis's own `env` (name-only reference) vs. `staticEnv` (literal
|
|
21
|
+
* value) split lives in one JSON object on disk (`jsonMcp.ts`'s
|
|
22
|
+
* `renderJsonServerEntry`) — a value is a "name" entry only when it's
|
|
23
|
+
* exactly `${KEY}` referencing its OWN key, matching exactly what that
|
|
24
|
+
* renderer ever produces; anything else (a literal value, or a `${...}`
|
|
25
|
+
* referencing a *different* name) is treated as a literal `staticEnv`
|
|
26
|
+
* value instead of guessed at.
|
|
27
|
+
*/
|
|
28
|
+
const SELF_VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
29
|
+
function splitJsonEnvMap(env) {
|
|
30
|
+
if (!env)
|
|
31
|
+
return {};
|
|
32
|
+
const names = [];
|
|
33
|
+
const literal = {};
|
|
34
|
+
for (const [key, value] of Object.entries(env)) {
|
|
35
|
+
const match = SELF_VAR_REF_RE.exec(value);
|
|
36
|
+
if (match && match[1] === key) {
|
|
37
|
+
names.push(key);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
literal[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const result = {};
|
|
44
|
+
if (names.length > 0)
|
|
45
|
+
result.env = names;
|
|
46
|
+
if (Object.keys(literal).length > 0)
|
|
47
|
+
result.staticEnv = literal;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function fromRichJsonServerDef(raw) {
|
|
51
|
+
if (raw.url) {
|
|
52
|
+
const transport = raw.type === "sse" ? "sse" : "http";
|
|
53
|
+
const def = { transport, url: raw.url };
|
|
54
|
+
if (raw.headers && Object.keys(raw.headers).length > 0)
|
|
55
|
+
def.headers = raw.headers;
|
|
56
|
+
return def;
|
|
57
|
+
}
|
|
58
|
+
if (!raw.command)
|
|
59
|
+
return undefined;
|
|
60
|
+
const def = { transport: "stdio", command: raw.command };
|
|
61
|
+
if (raw.args && raw.args.length > 0)
|
|
62
|
+
def.args = raw.args;
|
|
63
|
+
Object.assign(def, splitJsonEnvMap(raw.env));
|
|
64
|
+
return def;
|
|
65
|
+
}
|
|
66
|
+
function readRichJsonMcpDefs(configPath) {
|
|
67
|
+
const parsed = readJsonFile(configPath);
|
|
68
|
+
const entries = [];
|
|
69
|
+
const unsupported = [];
|
|
70
|
+
for (const [name, raw] of Object.entries(parsed?.mcpServers ?? {})) {
|
|
71
|
+
const def = fromRichJsonServerDef(raw);
|
|
72
|
+
if (def) {
|
|
73
|
+
entries.push({ name, def });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
unsupported.push({ name, reason: "stdio entry has no command — malformed, skipped" });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { entries, unsupported };
|
|
80
|
+
}
|
|
81
|
+
export function readClaudeCodeMcpDefs(homeDir) {
|
|
82
|
+
return readRichJsonMcpDefs(join(homeDir, ".claude.json"));
|
|
83
|
+
}
|
|
84
|
+
export function readKiroMcpDefs(homeDir) {
|
|
85
|
+
return readRichJsonMcpDefs(join(homeDir, ".kiro", "settings", "mcp.json"));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pure conversion half — separated from the subprocess/file-read effects
|
|
89
|
+
* below so the stdio/non-stdio/malformed decision logic is unit-testable
|
|
90
|
+
* without a real `codex` binary on PATH (this codebase has no fake-codex
|
|
91
|
+
* fixture anywhere; codex's subprocess dependency is otherwise entirely
|
|
92
|
+
* untested at the unit level).
|
|
93
|
+
*/
|
|
94
|
+
export function buildCodexMcpReadResult(mcpEntries, tomlContent) {
|
|
95
|
+
const entries = [];
|
|
96
|
+
const unsupported = [];
|
|
97
|
+
for (const entry of mcpEntries) {
|
|
98
|
+
if (entry.transport.type !== "stdio") {
|
|
99
|
+
const remote = buildCodexRemoteDef(entry);
|
|
100
|
+
if (remote.def) {
|
|
101
|
+
entries.push({ name: entry.name, def: remote.def });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
unsupported.push({ name: entry.name, reason: remote.reason });
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.transport.command) {
|
|
109
|
+
unsupported.push({ name: entry.name, reason: "stdio entry has no command — malformed, skipped" });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const def = { transport: "stdio", command: entry.transport.command };
|
|
113
|
+
if (entry.transport.args && entry.transport.args.length > 0)
|
|
114
|
+
def.args = entry.transport.args;
|
|
115
|
+
if (entry.transport.env_vars && entry.transport.env_vars.length > 0)
|
|
116
|
+
def.env = entry.transport.env_vars;
|
|
117
|
+
if (tomlContent) {
|
|
118
|
+
const staticEnv = readServerEnvTable(tomlContent, entry.name);
|
|
119
|
+
if (staticEnv && Object.keys(staticEnv).length > 0)
|
|
120
|
+
def.staticEnv = staticEnv;
|
|
121
|
+
}
|
|
122
|
+
entries.push({ name: entry.name, def });
|
|
123
|
+
}
|
|
124
|
+
return { entries, unsupported };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A non-stdio Codex entry converts only when it's exactly `url` (+
|
|
128
|
+
* optional `bearer_token_env_var`) — the one shape this project has
|
|
129
|
+
* verified evidence for, and the only shape Trellis's own writer
|
|
130
|
+
* (`renderServerSection`'s else-branch) ever produces for Codex. Any of
|
|
131
|
+
* the three unexplained `*headers*` fields being non-null means the real
|
|
132
|
+
* server uses a mechanism this codebase has no verified shape for — that
|
|
133
|
+
* entry stays unsupported rather than silently dropping whatever those
|
|
134
|
+
* fields represent.
|
|
135
|
+
*/
|
|
136
|
+
function buildCodexRemoteDef(entry) {
|
|
137
|
+
const { url, bearer_token_env_var: bearerVar, http_headers, env_http_headers, http_headers_helper } = entry.transport;
|
|
138
|
+
if (http_headers != null || env_http_headers != null || http_headers_helper != null) {
|
|
139
|
+
return { reason: `codex migrate-in does not support this server's header mechanism (http_headers/env_http_headers/http_headers_helper) — no verified shape for it` };
|
|
140
|
+
}
|
|
141
|
+
if (!url) {
|
|
142
|
+
return { reason: `codex migrate-in: non-stdio entry has no url — malformed, skipped` };
|
|
143
|
+
}
|
|
144
|
+
const def = { transport: "http", url };
|
|
145
|
+
if (bearerVar) {
|
|
146
|
+
def.headers = { Authorization: `Bearer \${${bearerVar}}` };
|
|
147
|
+
}
|
|
148
|
+
return { def };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Stdio only (design.md D2) — `codex mcp list --json`'s own output never
|
|
152
|
+
* exposes `url`/`bearer_token_env_var` in this codebase's `CodexMcpEntry`
|
|
153
|
+
* (src/probes/codex.ts), and this codebase has no verified evidence for
|
|
154
|
+
* what that subprocess reports for a non-stdio server. Guessing at an
|
|
155
|
+
* external tool's unverified output shape is exactly what this project's
|
|
156
|
+
* "verify, don't assume" discipline exists to prevent — a non-stdio
|
|
157
|
+
* entry is reported as unsupported instead.
|
|
158
|
+
*/
|
|
159
|
+
export function readCodexMcpDefs(homeDir) {
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
// `codex` resolves its own config via $HOME (confirmed by running it
|
|
163
|
+
// with an overridden HOME against an empty scratch dir — it returns
|
|
164
|
+
// `[]`, not the real machine's servers), so this must be scoped to
|
|
165
|
+
// `homeDir` explicitly — `src/probes/codex.ts`'s own equivalent call
|
|
166
|
+
// has this same gap, unaddressed there; not touched by this change.
|
|
167
|
+
raw = execFileSync("codex", ["mcp", "list", "--json"], { encoding: "utf-8", timeout: 5_000, env: { ...process.env, HOME: homeDir } });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return EMPTY_RESULT;
|
|
171
|
+
}
|
|
172
|
+
let mcpEntries;
|
|
173
|
+
try {
|
|
174
|
+
mcpEntries = JSON.parse(raw);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return EMPTY_RESULT;
|
|
178
|
+
}
|
|
179
|
+
const configToml = join(homeDir, ".codex", "config.toml");
|
|
180
|
+
let tomlContent;
|
|
181
|
+
try {
|
|
182
|
+
tomlContent = readFileSync(configToml, "utf-8");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
tomlContent = undefined;
|
|
186
|
+
}
|
|
187
|
+
return buildCodexMcpReadResult(mcpEntries, tomlContent);
|
|
188
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted record of what `mcp sync` itself last wrote for each agent —
|
|
3
|
+
* the ownership marker a bare TOML/JSON key otherwise lacks (mcpPlan.ts's
|
|
4
|
+
* own D7 reasoning: unlike a skill's symlink, there's nothing on disk to
|
|
5
|
+
* prove Trellis, not the user, put a given entry there). This ledger is
|
|
6
|
+
* that proof, kept separately from canonical (`~/.trellis/mcp/
|
|
7
|
+
* servers.yaml`) since it's Trellis's own private bookkeeping, not
|
|
8
|
+
* user-authored content.
|
|
9
|
+
*
|
|
10
|
+
* Stores each entry as the exact *rendered* value written at the time
|
|
11
|
+
* (a plain object for JSON agents, a TOML section string for Codex) —
|
|
12
|
+
* deliberately not a hash, so the later "is this still what we wrote"
|
|
13
|
+
* check can reuse each format's own already-existing equality check
|
|
14
|
+
* (`deepEqual` for JSON, `===` for TOML text) instead of a second,
|
|
15
|
+
* parallel comparison mechanism.
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentId } from "../core/types.js";
|
|
18
|
+
export type McpOwnershipLedger = Record<string, Record<string, unknown>>;
|
|
19
|
+
export declare function mcpOwnershipPath(homeDir: string): string;
|
|
20
|
+
export declare function loadMcpOwnership(homeDir: string): McpOwnershipLedger;
|
|
21
|
+
export declare function saveMcpOwnership(homeDir: string, ledger: McpOwnershipLedger): void;
|
|
22
|
+
/** This agent's own slice — `{}` if nothing has ever been recorded for it. */
|
|
23
|
+
export declare function ownedByAgent(ledger: McpOwnershipLedger, agentId: AgentId): Record<string, unknown>;
|
|
24
|
+
export declare function recordOwned(ledger: McpOwnershipLedger, agentId: AgentId, name: string, rendered: unknown): McpOwnershipLedger;
|
|
25
|
+
export declare function forgetOwned(ledger: McpOwnershipLedger, agentId: AgentId, name: string): McpOwnershipLedger;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted record of what `mcp sync` itself last wrote for each agent —
|
|
3
|
+
* the ownership marker a bare TOML/JSON key otherwise lacks (mcpPlan.ts's
|
|
4
|
+
* own D7 reasoning: unlike a skill's symlink, there's nothing on disk to
|
|
5
|
+
* prove Trellis, not the user, put a given entry there). This ledger is
|
|
6
|
+
* that proof, kept separately from canonical (`~/.trellis/mcp/
|
|
7
|
+
* servers.yaml`) since it's Trellis's own private bookkeeping, not
|
|
8
|
+
* user-authored content.
|
|
9
|
+
*
|
|
10
|
+
* Stores each entry as the exact *rendered* value written at the time
|
|
11
|
+
* (a plain object for JSON agents, a TOML section string for Codex) —
|
|
12
|
+
* deliberately not a hash, so the later "is this still what we wrote"
|
|
13
|
+
* check can reuse each format's own already-existing equality check
|
|
14
|
+
* (`deepEqual` for JSON, `===` for TOML text) instead of a second,
|
|
15
|
+
* parallel comparison mechanism.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
export function mcpOwnershipPath(homeDir) {
|
|
20
|
+
return join(homeDir, ".trellis", "mcp", "ownership.json");
|
|
21
|
+
}
|
|
22
|
+
export function loadMcpOwnership(homeDir) {
|
|
23
|
+
const path = mcpOwnershipPath(homeDir);
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return {};
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {}; // unreadable/corrupt ledger — treat as "nothing recorded yet", never crash a sync over it
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function saveMcpOwnership(homeDir, ledger) {
|
|
34
|
+
mkdirSync(join(homeDir, ".trellis", "mcp"), { recursive: true });
|
|
35
|
+
writeFileSync(mcpOwnershipPath(homeDir), `${JSON.stringify(ledger, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
/** This agent's own slice — `{}` if nothing has ever been recorded for it. */
|
|
38
|
+
export function ownedByAgent(ledger, agentId) {
|
|
39
|
+
return ledger[agentId] ?? {};
|
|
40
|
+
}
|
|
41
|
+
export function recordOwned(ledger, agentId, name, rendered) {
|
|
42
|
+
return { ...ledger, [agentId]: { ...(ledger[agentId] ?? {}), [name]: rendered } };
|
|
43
|
+
}
|
|
44
|
+
export function forgetOwned(ledger, agentId, name) {
|
|
45
|
+
if (!(name in (ledger[agentId] ?? {})))
|
|
46
|
+
return ledger;
|
|
47
|
+
const agentEntries = { ...ledger[agentId] };
|
|
48
|
+
delete agentEntries[name];
|
|
49
|
+
return { ...ledger, [agentId]: agentEntries };
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts canonical `memories/*.md` into `@modelcontextprotocol/
|
|
3
|
+
* server-memory`'s own on-disk JSON-lines knowledge-graph format, and
|
|
4
|
+
* upserts them into an existing graph file without disturbing anything
|
|
5
|
+
* else in it (trellis-memory-sync) — closing P6's explicitly-left-open
|
|
6
|
+
* "auto-ingesting memories/*.md content into the running memory
|
|
7
|
+
* server's store" gap.
|
|
8
|
+
*
|
|
9
|
+
* Each line in that file is one JSON object, either
|
|
10
|
+
* `{type:"entity", name, entityType, observations}` or
|
|
11
|
+
* `{type:"relation", from, to, relationType}` — the real, documented
|
|
12
|
+
* shape the official server persists and reads back on its own next
|
|
13
|
+
* startup (this project writes the file directly; it never spawns or
|
|
14
|
+
* talks to a running server process).
|
|
15
|
+
*
|
|
16
|
+
* `entityType: "trellis-memory"` is this project's own in-band ownership
|
|
17
|
+
* marker — simpler than a separate ledger file (unlike MCP server sync,
|
|
18
|
+
* every agent connected to this one server shares the exact same graph,
|
|
19
|
+
* so there's no per-agent rendering to track). An existing entity with
|
|
20
|
+
* the same name but a different `entityType` was created by something
|
|
21
|
+
* else (an agent's own runtime tool calls, most likely) and is left
|
|
22
|
+
* completely untouched — reported as a conflict, never overwritten.
|
|
23
|
+
*/
|
|
24
|
+
import type { CanonicalSource } from "../core/types.js";
|
|
25
|
+
export interface MemoryEntity {
|
|
26
|
+
type: "entity";
|
|
27
|
+
name: string;
|
|
28
|
+
entityType: string;
|
|
29
|
+
observations: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface MemoryRelation {
|
|
32
|
+
type: "relation";
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
relationType: string;
|
|
36
|
+
}
|
|
37
|
+
export type MemoryGraphLine = MemoryEntity | MemoryRelation;
|
|
38
|
+
export declare const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
|
|
39
|
+
export declare function parseMemoryGraph(content: string): MemoryGraphLine[];
|
|
40
|
+
export declare function renderMemoryGraph(lines: readonly MemoryGraphLine[]): string;
|
|
41
|
+
export type MemorySyncAction = "create" | "already-synced" | "remove" | "conflict";
|
|
42
|
+
export interface MemorySyncItem {
|
|
43
|
+
name: string;
|
|
44
|
+
action: MemorySyncAction;
|
|
45
|
+
detail: string;
|
|
46
|
+
}
|
|
47
|
+
export interface MemorySyncPlan {
|
|
48
|
+
items: MemorySyncItem[];
|
|
49
|
+
/** The full graph content to write — `undefined` when there is
|
|
50
|
+
* nothing to change (no create/remove items). */
|
|
51
|
+
nextGraph?: MemoryGraphLine[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pure: computes the next graph state and a human-readable plan, given
|
|
55
|
+
* the current graph's raw content (or `undefined` if the file doesn't
|
|
56
|
+
* exist yet) and canonical's memory entries. Every non-`trellis-memory`
|
|
57
|
+
* entity and every relation passes through completely untouched,
|
|
58
|
+
* regardless of what canonical wants.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planMemorySync(canonical: Pick<CanonicalSource, "memories">, currentGraphContent: string | undefined): MemorySyncPlan;
|