agent-trellis 0.1.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.
Files changed (67) hide show
  1. package/README.md +69 -17
  2. package/dist/adapters/claude-code.d.ts +5 -3
  3. package/dist/adapters/claude-code.js +27 -14
  4. package/dist/adapters/codex.d.ts +8 -4
  5. package/dist/adapters/codex.js +47 -16
  6. package/dist/adapters/jsonMcp.d.ts +16 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +5 -3
  9. package/dist/adapters/kiro.js +29 -16
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +40 -7
  12. package/dist/adapters/pi.d.ts +2 -1
  13. package/dist/adapters/pi.js +4 -4
  14. package/dist/adapters/symlinkPlan.d.ts +7 -3
  15. package/dist/adapters/symlinkPlan.js +42 -16
  16. package/dist/cli.js +161 -18
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +114 -7
  19. package/dist/commands/mcp.js +258 -17
  20. package/dist/commands/memory.d.ts +39 -0
  21. package/dist/commands/memory.js +78 -0
  22. package/dist/commands/migrate.d.ts +30 -4
  23. package/dist/commands/migrate.js +83 -16
  24. package/dist/commands/onboard.d.ts +52 -7
  25. package/dist/commands/onboard.js +318 -35
  26. package/dist/commands/rollback.d.ts +44 -0
  27. package/dist/commands/rollback.js +201 -0
  28. package/dist/commands/secretsAudit.d.ts +7 -0
  29. package/dist/commands/secretsAudit.js +14 -7
  30. package/dist/commands/skill.d.ts +51 -0
  31. package/dist/commands/skill.js +104 -0
  32. package/dist/commands/sync.d.ts +13 -0
  33. package/dist/commands/sync.js +31 -5
  34. package/dist/core/adapter.d.ts +28 -11
  35. package/dist/core/adapter.js +2 -2
  36. package/dist/core/canonical.d.ts +26 -1
  37. package/dist/core/canonical.js +103 -3
  38. package/dist/core/types.d.ts +29 -1
  39. package/dist/core/types.js +11 -2
  40. package/dist/lib/backup.d.ts +56 -0
  41. package/dist/lib/backup.js +98 -0
  42. package/dist/lib/deepEqual.d.ts +8 -0
  43. package/dist/lib/deepEqual.js +26 -0
  44. package/dist/lib/dirEquals.d.ts +9 -0
  45. package/dist/lib/dirEquals.js +15 -1
  46. package/dist/lib/installAgent.d.ts +26 -0
  47. package/dist/lib/installAgent.js +46 -0
  48. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  49. package/dist/lib/mcpMigrateRead.js +188 -0
  50. package/dist/lib/mcpOwnership.d.ts +25 -0
  51. package/dist/lib/mcpOwnership.js +50 -0
  52. package/dist/lib/memoryGraph.d.ts +60 -0
  53. package/dist/lib/memoryGraph.js +101 -0
  54. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  55. package/dist/lib/realHomeSnapshot.js +77 -0
  56. package/dist/lib/terminalPicker.d.ts +45 -0
  57. package/dist/lib/terminalPicker.js +193 -0
  58. package/dist/lib/tomlSection.d.ts +20 -6
  59. package/dist/lib/tomlSection.js +78 -12
  60. package/dist/pi-bridge/bundle.js +100 -51
  61. package/dist/pi-bridge/index.js +14 -2
  62. package/dist/probes/codex.js +10 -2
  63. package/docs/architecture.md +7 -4
  64. package/docs/getting-started.md +267 -33
  65. package/docs/roadmap.md +444 -0
  66. package/package.json +1 -1
  67. package/schema/servers.example.yaml +39 -2
@@ -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;
@@ -0,0 +1,101 @@
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 { readFileSync } from "node:fs";
25
+ export const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
26
+ export function parseMemoryGraph(content) {
27
+ const lines = [];
28
+ for (const raw of content.split("\n")) {
29
+ const trimmed = raw.trim();
30
+ if (!trimmed)
31
+ continue;
32
+ try {
33
+ const parsed = JSON.parse(trimmed);
34
+ if (parsed.type === "entity" || parsed.type === "relation") {
35
+ lines.push(parsed);
36
+ }
37
+ }
38
+ catch {
39
+ // Not valid JSON — skip rather than fail the whole file; a
40
+ // hand-edited or partially-written line shouldn't block every
41
+ // other, unrelated line in the graph.
42
+ }
43
+ }
44
+ return lines;
45
+ }
46
+ export function renderMemoryGraph(lines) {
47
+ if (lines.length === 0)
48
+ return "";
49
+ return `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`;
50
+ }
51
+ /**
52
+ * Pure: computes the next graph state and a human-readable plan, given
53
+ * the current graph's raw content (or `undefined` if the file doesn't
54
+ * exist yet) and canonical's memory entries. Every non-`trellis-memory`
55
+ * entity and every relation passes through completely untouched,
56
+ * regardless of what canonical wants.
57
+ */
58
+ export function planMemorySync(canonical, currentGraphContent) {
59
+ const existingLines = currentGraphContent !== undefined ? parseMemoryGraph(currentGraphContent) : [];
60
+ const untouchedLines = existingLines.filter((line) => !(line.type === "entity" && line.entityType === TRELLIS_MEMORY_ENTITY_TYPE));
61
+ const existingTrellisEntities = new Map(existingLines.filter((line) => line.type === "entity" && line.entityType === TRELLIS_MEMORY_ENTITY_TYPE).map((e) => [e.name, e]));
62
+ const existingOtherEntityNames = new Set(existingLines.filter((line) => line.type === "entity" && line.entityType !== TRELLIS_MEMORY_ENTITY_TYPE).map((e) => e.name));
63
+ const items = [];
64
+ const nextTrellisEntities = [];
65
+ let changed = false;
66
+ const canonicalNames = new Set(canonical.memories.map((m) => m.name));
67
+ for (const memory of canonical.memories) {
68
+ if (existingOtherEntityNames.has(memory.name)) {
69
+ items.push({ name: memory.name, action: "conflict", detail: `an entity named "${memory.name}" already exists in the graph and wasn't created by Trellis — resolve by hand` });
70
+ continue;
71
+ }
72
+ let content;
73
+ try {
74
+ content = readFileSync(memory.file, "utf-8");
75
+ }
76
+ catch (err) {
77
+ items.push({ name: memory.name, action: "conflict", detail: `could not read ${memory.file}: ${err instanceof Error ? err.message : String(err)}` });
78
+ continue;
79
+ }
80
+ const desired = { type: "entity", name: memory.name, entityType: TRELLIS_MEMORY_ENTITY_TYPE, observations: [content] };
81
+ const existing = existingTrellisEntities.get(memory.name);
82
+ nextTrellisEntities.push(desired);
83
+ if (existing && existing.observations.length === 1 && existing.observations[0] === content) {
84
+ items.push({ name: memory.name, action: "already-synced", detail: "graph content is already identical" });
85
+ }
86
+ else {
87
+ items.push({ name: memory.name, action: "create", detail: existing ? "will update the existing entity's observation" : "will create a new entity" });
88
+ changed = true;
89
+ }
90
+ }
91
+ for (const [name] of existingTrellisEntities) {
92
+ if (canonicalNames.has(name))
93
+ continue; // still wanted, handled above
94
+ items.push({ name, action: "remove", detail: "no longer in canonical — will be removed from the graph" });
95
+ changed = true;
96
+ }
97
+ if (!changed) {
98
+ return { items };
99
+ }
100
+ return { items, nextGraph: [...untouchedLines, ...nextTrellisEntities] };
101
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `--real` sandbox mode's allowlist (trellis-real-sandbox-verification
3
+ * design.md D1): every real dotfile path a probe (src/probes/*.ts) is
4
+ * already known to read, and nothing else. Allowlist, not denylist —
5
+ * anything this project doesn't already know to be structural (real
6
+ * OAuth token storage, session/history logs, credentials) is never
7
+ * copied out of a developer's real `$HOME` because it was never named
8
+ * here, not because it was excluded after the fact. Kept in sync with
9
+ * each probe's own `join(homeDir, ...)` calls by direct source
10
+ * cross-reference, not by assumption.
11
+ */
12
+ export declare const REAL_HOME_ALLOWLIST: readonly string[];
13
+ /**
14
+ * Copies only allowlisted paths that actually exist under `sourceHome`
15
+ * into `destHome` — a missing entry is a normal, expected state (not
16
+ * every agent is installed), never an error. `dereference: true` is
17
+ * required, not incidental: `sync`'s own real output is symlinks
18
+ * (skills, instructions) pointing back at `sourceHome`'s own
19
+ * `.trellis/` — a container mounting a snapshot that kept those
20
+ * symlinks as symlinks would get a dangling reference to a path that
21
+ * doesn't exist inside it. Copying real content instead is what makes
22
+ * the snapshot self-contained (found by actually running this against
23
+ * a real, already-synced machine — not by inspection). Returns the
24
+ * relative paths actually copied, for the caller to report.
25
+ */
26
+ export declare function buildRealHomeSnapshot(sourceHome: string, destHome: string): string[];
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `--real` sandbox mode's allowlist (trellis-real-sandbox-verification
3
+ * design.md D1): every real dotfile path a probe (src/probes/*.ts) is
4
+ * already known to read, and nothing else. Allowlist, not denylist —
5
+ * anything this project doesn't already know to be structural (real
6
+ * OAuth token storage, session/history logs, credentials) is never
7
+ * copied out of a developer's real `$HOME` because it was never named
8
+ * here, not because it was excluded after the fact. Kept in sync with
9
+ * each probe's own `join(homeDir, ...)` calls by direct source
10
+ * cross-reference, not by assumption.
11
+ */
12
+ import { cpSync, existsSync, mkdirSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ export const REAL_HOME_ALLOWLIST = [
15
+ // claude-code (src/probes/claude-code.ts)
16
+ ".claude.json",
17
+ ".claude/skills",
18
+ ".claude/agents",
19
+ ".claude/CLAUDE.md",
20
+ // codex (src/probes/codex.ts)
21
+ ".codex/config.toml",
22
+ ".agents/skills",
23
+ ".codex/skills",
24
+ // kiro (src/probes/kiro.ts)
25
+ ".kiro/settings/mcp.json",
26
+ ".kiro/skills",
27
+ ".kiro/steering/CLAUDE.md",
28
+ // pi (src/probes/pi.ts)
29
+ ".pi/agent/settings.json",
30
+ ".pi/agent/skills",
31
+ ".pi/agent/AGENTS.override.md",
32
+ ".pi/agent/AGENTS.md",
33
+ ".pi/agent/AGENTS.MD",
34
+ ".pi/agent/CLAUDE.md",
35
+ ".pi/agent/CLAUDE.MD",
36
+ // this machine's own real canonical source, if it already has one —
37
+ // Trellis's own managed data, not a third-party agent's.
38
+ ".trellis",
39
+ ];
40
+ /**
41
+ * Copies only allowlisted paths that actually exist under `sourceHome`
42
+ * into `destHome` — a missing entry is a normal, expected state (not
43
+ * every agent is installed), never an error. `dereference: true` is
44
+ * required, not incidental: `sync`'s own real output is symlinks
45
+ * (skills, instructions) pointing back at `sourceHome`'s own
46
+ * `.trellis/` — a container mounting a snapshot that kept those
47
+ * symlinks as symlinks would get a dangling reference to a path that
48
+ * doesn't exist inside it. Copying real content instead is what makes
49
+ * the snapshot self-contained (found by actually running this against
50
+ * a real, already-synced machine — not by inspection). Returns the
51
+ * relative paths actually copied, for the caller to report.
52
+ */
53
+ export function buildRealHomeSnapshot(sourceHome, destHome) {
54
+ const copied = [];
55
+ for (const rel of REAL_HOME_ALLOWLIST) {
56
+ const src = join(sourceHome, rel);
57
+ if (!existsSync(src))
58
+ continue;
59
+ const dest = join(destHome, rel);
60
+ // On a case-insensitive filesystem (macOS default), two distinct
61
+ // allowlist entries (e.g. `AGENTS.md`/`AGENTS.MD` — pi's own
62
+ // case-sensitive candidate list, src/probes/pi.ts) can resolve to
63
+ // the identical real file; a `dest` an earlier entry already
64
+ // created (case-insensitively) needs no second, redundant copy —
65
+ // found by actually running this against a real machine, where it
66
+ // also tripped a Node `cpSync` quirk re-copying a symlink onto its
67
+ // own already-materialized destination.
68
+ if (existsSync(dest)) {
69
+ copied.push(rel);
70
+ continue;
71
+ }
72
+ mkdirSync(dirname(dest), { recursive: true });
73
+ cpSync(src, dest, { recursive: true, dereference: true });
74
+ copied.push(rel);
75
+ }
76
+ return copied;
77
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Minimal, dependency-free arrow-key/checkbox terminal picker
3
+ * (trellis-onboard-interactive-picker design.md D1) — the interaction
4
+ * surface `trellis onboard`'s two prompts need (at most four rows, no
5
+ * search, no pagination) doesn't need a full prompt library; this
6
+ * hand-rolls just enough raw-mode key handling and ANSI rendering for
7
+ * that bounded case, matching the project's existing narrow-dependency
8
+ * precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
9
+ *
10
+ * `input`/`output` are injectable (never a CLI flag) purely so tests can
11
+ * drive the real key-parsing/render loop against a plain stream instead
12
+ * of a real TTY — same seam pattern (`homeDir`, etc.) used everywhere
13
+ * else in this project.
14
+ */
15
+ import type { Readable, Writable } from "node:stream";
16
+ export interface PickerStreams {
17
+ input: NodeJS.ReadStream | Readable;
18
+ output: NodeJS.WriteStream | Writable;
19
+ }
20
+ /**
21
+ * True only when both streams are real, raw-mode-capable terminals — the
22
+ * exact gate deciding picker vs. the pre-existing numbered-typing prompt
23
+ * (design.md D2). A stream lacking `setRawMode` (piped input, some
24
+ * minimal TTYs) always falls back, never hangs or guesses.
25
+ */
26
+ export declare function canUseInteractivePicker(streams?: PickerStreams): boolean;
27
+ /** Wrapping index navigation — pure, exported for direct unit testing. */
28
+ export declare function nextIndex(current: number, delta: number, length: number): number;
29
+ /** Pure single-index toggle — exported for direct unit testing. */
30
+ export declare function toggled(checked: readonly boolean[], index: number): boolean[];
31
+ /**
32
+ * Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
33
+ * the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
34
+ * must have already confirmed `canUseInteractivePicker()` — this function
35
+ * does not re-check, and assumes `streams.input` genuinely supports raw
36
+ * mode.
37
+ */
38
+ export declare function runSingleSelectPicker(items: string[], streams?: PickerStreams): Promise<number | null>;
39
+ /**
40
+ * Multi-select (checkbox): Up/Down/j/k moves the highlight, Space toggles
41
+ * the current row, Enter confirms. Resolves the checked indices at
42
+ * confirm time, or `null` on Ctrl+C cancel. Same precondition as
43
+ * `runSingleSelectPicker`.
44
+ */
45
+ export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams): Promise<number[] | null>;