agent-trellis 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/adapters/claude-code.d.ts +23 -0
- package/dist/adapters/claude-code.js +86 -0
- package/dist/adapters/codex.d.ts +27 -0
- package/dist/adapters/codex.js +119 -0
- package/dist/adapters/jsonMcp.d.ts +24 -0
- package/dist/adapters/jsonMcp.js +84 -0
- package/dist/adapters/kiro.d.ts +34 -0
- package/dist/adapters/kiro.js +175 -0
- package/dist/adapters/mcpPlan.d.ts +28 -0
- package/dist/adapters/mcpPlan.js +83 -0
- package/dist/adapters/pi.d.ts +23 -0
- package/dist/adapters/pi.js +108 -0
- package/dist/adapters/symlinkPlan.d.ts +33 -0
- package/dist/adapters/symlinkPlan.js +120 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +135 -0
- package/dist/commands/doctor.d.ts +88 -0
- package/dist/commands/doctor.js +269 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +150 -0
- package/dist/commands/mcp.d.ts +28 -0
- package/dist/commands/mcp.js +70 -0
- package/dist/commands/migrate.d.ts +38 -0
- package/dist/commands/migrate.js +132 -0
- package/dist/commands/onboard.d.ts +50 -0
- package/dist/commands/onboard.js +155 -0
- package/dist/commands/secretsAudit.d.ts +35 -0
- package/dist/commands/secretsAudit.js +115 -0
- package/dist/commands/sync.d.ts +40 -0
- package/dist/commands/sync.js +91 -0
- package/dist/core/adapter.d.ts +133 -0
- package/dist/core/adapter.js +16 -0
- package/dist/core/canonical.d.ts +16 -0
- package/dist/core/canonical.js +148 -0
- package/dist/core/types.d.ts +201 -0
- package/dist/core/types.js +15 -0
- package/dist/lib/dirEquals.d.ts +7 -0
- package/dist/lib/dirEquals.js +39 -0
- package/dist/lib/envVarNames.d.ts +35 -0
- package/dist/lib/envVarNames.js +79 -0
- package/dist/lib/fsIdentity.d.ts +16 -0
- package/dist/lib/fsIdentity.js +53 -0
- package/dist/lib/mcpProbe.d.ts +14 -0
- package/dist/lib/mcpProbe.js +96 -0
- package/dist/lib/probeCommon.d.ts +24 -0
- package/dist/lib/probeCommon.js +108 -0
- package/dist/lib/secretEnv.d.ts +19 -0
- package/dist/lib/secretEnv.js +46 -0
- package/dist/lib/skillFile.d.ts +12 -0
- package/dist/lib/skillFile.js +26 -0
- package/dist/lib/syncArgs.d.ts +16 -0
- package/dist/lib/syncArgs.js +17 -0
- package/dist/lib/tomlSection.d.ts +57 -0
- package/dist/lib/tomlSection.js +162 -0
- package/dist/pi-bridge/bundle.js +32074 -0
- package/dist/pi-bridge/index.d.ts +48 -0
- package/dist/pi-bridge/index.js +188 -0
- package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
- package/dist/pi-bridge/schemaTranslate.js +40 -0
- package/dist/probes/claude-code.d.ts +13 -0
- package/dist/probes/claude-code.js +48 -0
- package/dist/probes/codex.d.ts +24 -0
- package/dist/probes/codex.js +78 -0
- package/dist/probes/kiro.d.ts +12 -0
- package/dist/probes/kiro.js +48 -0
- package/dist/probes/pi.d.ts +14 -0
- package/dist/probes/pi.js +53 -0
- package/dist/sdk.d.ts +14 -0
- package/dist/sdk.js +13 -0
- package/docs/architecture.md +367 -0
- package/docs/getting-started.md +235 -0
- package/docs/implementation-plan.md +341 -0
- package/docs/research.md +175 -0
- package/docs/roadmap.md +484 -0
- package/package.json +59 -0
- package/schema/scope.example.yaml +33 -0
- package/schema/secrets.policy.example.yaml +43 -0
- package/schema/servers.example.yaml +87 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension entry point (trellis-pi-mcp-bridge-p4). Delivered by
|
|
3
|
+
* symlinking this exact file into `~/.pi/agent/extensions/`
|
|
4
|
+
* (src/adapters/pi.ts) — pi's own directory-based auto-discovery loads
|
|
5
|
+
* it with zero settings.json involvement (design.md D1).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately duck-typed against pi's extension API (no dependency on
|
|
8
|
+
* `@earendil-works/pi-coding-agent` itself — this file must work
|
|
9
|
+
* regardless of which pi version is installed on a given machine, and a
|
|
10
|
+
* type-only import would still require that package to be resolvable
|
|
11
|
+
* from Trellis's own install tree). Only the handful of fields/methods
|
|
12
|
+
* this bridge actually uses are declared here.
|
|
13
|
+
*/
|
|
14
|
+
import type { TSchema } from "typebox";
|
|
15
|
+
import { toPiContent } from "./schemaTranslate.js";
|
|
16
|
+
interface PiToolResult {
|
|
17
|
+
content: ReturnType<typeof toPiContent>;
|
|
18
|
+
details: unknown;
|
|
19
|
+
}
|
|
20
|
+
interface PiToolDefinition {
|
|
21
|
+
name: string;
|
|
22
|
+
label: string;
|
|
23
|
+
description: string;
|
|
24
|
+
parameters: TSchema;
|
|
25
|
+
execute(toolCallId: string, params: Record<string, unknown>): Promise<PiToolResult>;
|
|
26
|
+
}
|
|
27
|
+
interface PiExtensionAPI {
|
|
28
|
+
registerTool(tool: PiToolDefinition): void;
|
|
29
|
+
on?(event: "session_shutdown", handler: () => Promise<void> | void): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A hanging server (process alive, protocol response never sent) leaves
|
|
33
|
+
* `promise` permanently unsettled — indistinguishable from "still
|
|
34
|
+
* starting up" without a bound. Racing against a timeout converts that
|
|
35
|
+
* into an ordinary rejection, which every caller here already knows how
|
|
36
|
+
* to isolate (design.md D1, trellis-mcp-connect-timeout).
|
|
37
|
+
*/
|
|
38
|
+
export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
|
|
39
|
+
/**
|
|
40
|
+
* `homeDir`/`connectTimeoutMs` default to the real `~` / 10s — only
|
|
41
|
+
* overridable for tests, same seam every other Trellis entry point
|
|
42
|
+
* uses. Neither is something pi itself ever passes; this factory's own
|
|
43
|
+
* signature matches pi's `ExtensionFactory = (pi) => void | Promise<void>`
|
|
44
|
+
* exactly (both trailing params have defaults, so calling it as
|
|
45
|
+
* `factory(pi)` — what pi actually does — works unchanged).
|
|
46
|
+
*/
|
|
47
|
+
export default function trellisMcpBridge(pi: PiExtensionAPI, homeDir?: string, connectTimeoutMs?: number): Promise<void>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension entry point (trellis-pi-mcp-bridge-p4). Delivered by
|
|
3
|
+
* symlinking this exact file into `~/.pi/agent/extensions/`
|
|
4
|
+
* (src/adapters/pi.ts) — pi's own directory-based auto-discovery loads
|
|
5
|
+
* it with zero settings.json involvement (design.md D1).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately duck-typed against pi's extension API (no dependency on
|
|
8
|
+
* `@earendil-works/pi-coding-agent` itself — this file must work
|
|
9
|
+
* regardless of which pi version is installed on a given machine, and a
|
|
10
|
+
* type-only import would still require that package to be resolvable
|
|
11
|
+
* from Trellis's own install tree). Only the handful of fields/methods
|
|
12
|
+
* this bridge actually uses are declared here.
|
|
13
|
+
*/
|
|
14
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
16
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
17
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
20
|
+
import { resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
21
|
+
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
22
|
+
import { extractTemplateVarNames } from "../lib/envVarNames.js";
|
|
23
|
+
import { bridgedToolName, toParametersSchema, toPiContent } from "./schemaTranslate.js";
|
|
24
|
+
const CLIENT_INFO = { name: "trellis-mcp-bridge", version: "0.0.0" };
|
|
25
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
26
|
+
/**
|
|
27
|
+
* A hanging server (process alive, protocol response never sent) leaves
|
|
28
|
+
* `promise` permanently unsettled — indistinguishable from "still
|
|
29
|
+
* starting up" without a bound. Racing against a timeout converts that
|
|
30
|
+
* into an ordinary rejection, which every caller here already knows how
|
|
31
|
+
* to isolate (design.md D1, trellis-mcp-connect-timeout).
|
|
32
|
+
*/
|
|
33
|
+
export function withTimeout(promise, timeoutMs, message) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
36
|
+
promise.then((value) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
resolve(value);
|
|
39
|
+
}, (err) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
reject(err);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A timed-out (or otherwise failed) connect attempt must not leak the
|
|
47
|
+
* transport's own resources — for `StdioClientTransport` specifically,
|
|
48
|
+
* an unclosed transport means an orphaned child process that outlives
|
|
49
|
+
* this failed attempt indefinitely (confirmed by a real leaked
|
|
50
|
+
* subprocess during this change's own test run). `transport.close()` is
|
|
51
|
+
* best-effort: a transport that never fully connected may itself error
|
|
52
|
+
* on close, but the original connect failure is what the caller needs
|
|
53
|
+
* to see, not a secondary cleanup error.
|
|
54
|
+
*/
|
|
55
|
+
async function connectWithCleanup(client, transport, timeoutMs, message) {
|
|
56
|
+
try {
|
|
57
|
+
await withTimeout(client.connect(transport), timeoutMs, message);
|
|
58
|
+
return client;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
try {
|
|
62
|
+
await transport.close();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best-effort; the original connect failure is what matters
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function connectStdio(def, secretsPolicy, timeoutMs) {
|
|
71
|
+
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
72
|
+
const resolved = resolveSecretEnv(def.env ?? [], secretsPolicy);
|
|
73
|
+
const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
|
|
74
|
+
const transport = new StdioClientTransport({
|
|
75
|
+
command: def.command,
|
|
76
|
+
args: def.args,
|
|
77
|
+
env: { ...getDefaultEnvironment(), ...namedEnv },
|
|
78
|
+
});
|
|
79
|
+
return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
80
|
+
}
|
|
81
|
+
function resolveHeaders(def, secretsPolicy) {
|
|
82
|
+
if (!def.headers || Object.keys(def.headers).length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
|
|
85
|
+
const resolved = resolveSecretEnv(names, secretsPolicy);
|
|
86
|
+
const result = {};
|
|
87
|
+
for (const [key, template] of Object.entries(def.headers)) {
|
|
88
|
+
result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
async function connectHttp(def, secretsPolicy, timeoutMs) {
|
|
93
|
+
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
94
|
+
const headers = resolveHeaders(def, secretsPolicy);
|
|
95
|
+
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
96
|
+
return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
97
|
+
}
|
|
98
|
+
async function connectSse(def, secretsPolicy, timeoutMs) {
|
|
99
|
+
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
100
|
+
const headers = resolveHeaders(def, secretsPolicy);
|
|
101
|
+
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
102
|
+
return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
103
|
+
}
|
|
104
|
+
function registerServerTools(pi, serverName, client, timeoutMs) {
|
|
105
|
+
return withTimeout(client.listTools(), timeoutMs, `listTools timed out after ${timeoutMs}ms`).then((result) => {
|
|
106
|
+
for (const tool of result.tools) {
|
|
107
|
+
pi.registerTool({
|
|
108
|
+
name: bridgedToolName(serverName, tool.name),
|
|
109
|
+
label: tool.name,
|
|
110
|
+
description: tool.description ?? `MCP tool "${tool.name}" from server "${serverName}"`,
|
|
111
|
+
parameters: toParametersSchema(tool.inputSchema),
|
|
112
|
+
async execute(_toolCallId, params) {
|
|
113
|
+
const result = await client.callTool({ name: tool.name, arguments: params });
|
|
114
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
115
|
+
return { content: toPiContent(content), details: result };
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `homeDir`/`connectTimeoutMs` default to the real `~` / 10s — only
|
|
123
|
+
* overridable for tests, same seam every other Trellis entry point
|
|
124
|
+
* uses. Neither is something pi itself ever passes; this factory's own
|
|
125
|
+
* signature matches pi's `ExtensionFactory = (pi) => void | Promise<void>`
|
|
126
|
+
* exactly (both trailing params have defaults, so calling it as
|
|
127
|
+
* `factory(pi)` — what pi actually does — works unchanged).
|
|
128
|
+
*/
|
|
129
|
+
export default async function trellisMcpBridge(pi, homeDir = homedir(), connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS) {
|
|
130
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
131
|
+
const { desired } = resolveMcpPlan("pi", canonical.mcp);
|
|
132
|
+
const clients = new Set();
|
|
133
|
+
const closeClient = async (client) => {
|
|
134
|
+
if (!clients.delete(client))
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
await client.close();
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// Cleanup is best-effort; one transport must not block other servers.
|
|
141
|
+
console.error(`trellis-mcp-bridge: failed to close MCP client: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const closeAllClients = async () => {
|
|
145
|
+
const pending = [...clients];
|
|
146
|
+
clients.clear();
|
|
147
|
+
await Promise.all(pending.map(async (client) => {
|
|
148
|
+
try {
|
|
149
|
+
await client.close();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error(`trellis-mcp-bridge: failed to close MCP client: ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
}));
|
|
155
|
+
};
|
|
156
|
+
// pi 0.85.x emits this before tearing down an extension runtime. Keep the
|
|
157
|
+
// hook optional so the bridge remains loadable in older compatible hosts.
|
|
158
|
+
pi.on?.("session_shutdown", closeAllClients);
|
|
159
|
+
await Promise.all(desired.map(async ({ name, def }) => {
|
|
160
|
+
let client;
|
|
161
|
+
try {
|
|
162
|
+
if (def.transport === "http") {
|
|
163
|
+
client = await connectHttp(def, canonical.secretsPolicy, connectTimeoutMs);
|
|
164
|
+
}
|
|
165
|
+
else if (def.transport === "sse") {
|
|
166
|
+
client = await connectSse(def, canonical.secretsPolicy, connectTimeoutMs);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
client = await connectStdio(def, canonical.secretsPolicy, connectTimeoutMs);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
// One unreachable/misconfigured (including permanently hanging —
|
|
174
|
+
// trellis-mcp-connect-timeout) server must never prevent every
|
|
175
|
+
// other server's tools from registering (tasks.md 3.2).
|
|
176
|
+
console.error(`trellis-mcp-bridge: failed to connect to MCP server "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
clients.add(client);
|
|
180
|
+
try {
|
|
181
|
+
await registerServerTools(pi, name, client, connectTimeoutMs);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
console.error(`trellis-mcp-bridge: failed to list tools for MCP server "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
185
|
+
await closeClient(client);
|
|
186
|
+
}
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation between MCP's protocol shapes and pi's own extension
|
|
3
|
+
* API shapes (trellis-pi-mcp-bridge-p4 design.md D3/D4/D5). No I/O, no
|
|
4
|
+
* MCP client, no pi runtime — everything here is unit-testable in
|
|
5
|
+
* isolation from both.
|
|
6
|
+
*/
|
|
7
|
+
import { type TSchema } from "typebox";
|
|
8
|
+
/** MCP's `tools/call` result content item, narrowed to the fields this
|
|
9
|
+
* bridge actually reads — not the full protocol union. */
|
|
10
|
+
export type McpContentItem = {
|
|
11
|
+
type: "text";
|
|
12
|
+
text: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: "image";
|
|
15
|
+
data: string;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "audio";
|
|
19
|
+
data: string;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: "resource";
|
|
23
|
+
resource: {
|
|
24
|
+
uri: string;
|
|
25
|
+
mimeType?: string;
|
|
26
|
+
};
|
|
27
|
+
} | {
|
|
28
|
+
type: "resource_link";
|
|
29
|
+
uri: string;
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
export interface PiTextContent {
|
|
33
|
+
type: "text";
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
export interface PiImageContent {
|
|
37
|
+
type: "image";
|
|
38
|
+
data: string;
|
|
39
|
+
mimeType: string;
|
|
40
|
+
}
|
|
41
|
+
export type PiContent = PiTextContent | PiImageContent;
|
|
42
|
+
/** Wraps a raw MCP JSON Schema as a TypeBox `TSchema` via `Type.Unsafe` —
|
|
43
|
+
* the schema was already authored as valid JSON Schema by the MCP server,
|
|
44
|
+
* so it needs no TypeBox builder semantics applied to it, only a type
|
|
45
|
+
* that satisfies `ToolDefinition.parameters`'s `TSchema` bound
|
|
46
|
+
* (design.md D4). */
|
|
47
|
+
export declare function toParametersSchema(inputSchema: unknown): TSchema;
|
|
48
|
+
/** Text/image pass through unchanged; audio/resource/resource_link
|
|
49
|
+
* degrade to a text summary rather than being dropped silently — pi's
|
|
50
|
+
* own `AgentToolResult.content` has no representation for them
|
|
51
|
+
* (design.md D3). */
|
|
52
|
+
export declare function toPiContent(items: McpContentItem[]): PiContent[];
|
|
53
|
+
/** `${serverName}__${toolName}` — deterministic, collision-free across
|
|
54
|
+
* servers sharing pi.registerTool()'s single flat namespace (design.md D5). */
|
|
55
|
+
export declare function bridgedToolName(serverName: string, toolName: string): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation between MCP's protocol shapes and pi's own extension
|
|
3
|
+
* API shapes (trellis-pi-mcp-bridge-p4 design.md D3/D4/D5). No I/O, no
|
|
4
|
+
* MCP client, no pi runtime — everything here is unit-testable in
|
|
5
|
+
* isolation from both.
|
|
6
|
+
*/
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
/** Wraps a raw MCP JSON Schema as a TypeBox `TSchema` via `Type.Unsafe` —
|
|
9
|
+
* the schema was already authored as valid JSON Schema by the MCP server,
|
|
10
|
+
* so it needs no TypeBox builder semantics applied to it, only a type
|
|
11
|
+
* that satisfies `ToolDefinition.parameters`'s `TSchema` bound
|
|
12
|
+
* (design.md D4). */
|
|
13
|
+
export function toParametersSchema(inputSchema) {
|
|
14
|
+
return Type.Unsafe(inputSchema);
|
|
15
|
+
}
|
|
16
|
+
/** Text/image pass through unchanged; audio/resource/resource_link
|
|
17
|
+
* degrade to a text summary rather than being dropped silently — pi's
|
|
18
|
+
* own `AgentToolResult.content` has no representation for them
|
|
19
|
+
* (design.md D3). */
|
|
20
|
+
export function toPiContent(items) {
|
|
21
|
+
return items.map((item) => {
|
|
22
|
+
switch (item.type) {
|
|
23
|
+
case "text":
|
|
24
|
+
return { type: "text", text: item.text };
|
|
25
|
+
case "image":
|
|
26
|
+
return { type: "image", data: item.data, mimeType: item.mimeType };
|
|
27
|
+
case "audio":
|
|
28
|
+
return { type: "text", text: `[audio content omitted, mimeType=${item.mimeType} — pi has no native audio tool-result type]` };
|
|
29
|
+
case "resource":
|
|
30
|
+
return { type: "text", text: `[resource content omitted, uri=${item.resource.uri} — pi has no native resource tool-result type]` };
|
|
31
|
+
case "resource_link":
|
|
32
|
+
return { type: "text", text: `[resource link omitted: ${item.name} (${item.uri}) — pi has no native resource tool-result type]` };
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** `${serverName}__${toolName}` — deterministic, collision-free across
|
|
37
|
+
* servers sharing pi.registerTool()'s single flat namespace (design.md D5). */
|
|
38
|
+
export function bridgedToolName(serverName, toolName) {
|
|
39
|
+
return `${serverName}__${toolName}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code probe: `~/.claude.json` (`mcpServers`), `~/.claude/skills`,
|
|
3
|
+
* `~/.claude/agents`, `~/.claude/CLAUDE.md`. Built first (tasks.md 4.1) to
|
|
4
|
+
* validate the shared `AgentSnapshot` shape before the other three probes
|
|
5
|
+
* commit to it.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentSnapshot } from "../core/types.js";
|
|
8
|
+
export interface ProbeOptions {
|
|
9
|
+
/** Spawn each configured stdio server for a live handshake. Off by
|
|
10
|
+
* default — see docs/architecture.md "MCP handshake probing is opt-in". */
|
|
11
|
+
probeMcp?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function probe(homeDir?: string, opts?: ProbeOptions): Promise<AgentSnapshot>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code probe: `~/.claude.json` (`mcpServers`), `~/.claude/skills`,
|
|
3
|
+
* `~/.claude/agents`, `~/.claude/CLAUDE.md`. Built first (tasks.md 4.1) to
|
|
4
|
+
* validate the shared `AgentSnapshot` shape before the other three probes
|
|
5
|
+
* commit to it.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { probeMcpServer } from "../lib/mcpProbe.js";
|
|
11
|
+
import { countDirEntries, pathRef, readJsonFile, resolveEnvRefs, scanSkillRoot } from "../lib/probeCommon.js";
|
|
12
|
+
export async function probe(homeDir = homedir(), opts = {}) {
|
|
13
|
+
const claudeJson = readJsonFile(join(homeDir, ".claude.json"));
|
|
14
|
+
if (claudeJson === undefined) {
|
|
15
|
+
return { agent: "claude-code", present: false, skillRoots: [], mcpServers: [], diagnostics: [] };
|
|
16
|
+
}
|
|
17
|
+
let version;
|
|
18
|
+
try {
|
|
19
|
+
version = execFileSync("claude", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Config exists without the CLI on PATH — still probeable, just unversioned.
|
|
23
|
+
}
|
|
24
|
+
const mcpServers = Object.entries(claudeJson.mcpServers ?? {}).map(([name, def]) => ({
|
|
25
|
+
name,
|
|
26
|
+
transport: def.url ? "http" : "stdio",
|
|
27
|
+
}));
|
|
28
|
+
if (opts.probeMcp) {
|
|
29
|
+
await Promise.all(Object.entries(claudeJson.mcpServers ?? {}).map(async ([name, def], index) => {
|
|
30
|
+
if (def.url || !def.command)
|
|
31
|
+
return;
|
|
32
|
+
mcpServers[index].probe = await probeMcpServer({ transport: "stdio", command: def.command, args: def.args }, resolveEnvRefs(def.env, process.env));
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
const skillRoot = scanSkillRoot(join(homeDir, ".claude", "skills"));
|
|
36
|
+
const agentsDir = join(homeDir, ".claude", "agents");
|
|
37
|
+
const agentsRef = pathRef(agentsDir);
|
|
38
|
+
return {
|
|
39
|
+
agent: "claude-code",
|
|
40
|
+
present: true,
|
|
41
|
+
version,
|
|
42
|
+
skillRoots: skillRoot ? [skillRoot] : [],
|
|
43
|
+
mcpServers,
|
|
44
|
+
instructionsFile: pathRef(join(homeDir, ".claude", "CLAUDE.md")),
|
|
45
|
+
subagentsDir: agentsRef ? { ...agentsRef, count: countDirEntries(agentsDir) } : undefined,
|
|
46
|
+
diagnostics: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex probe: MCP inventory via `codex mcp list --json` (design.md D2),
|
|
3
|
+
* not TOML parsing — Codex already exposes this, so a read-only phase has
|
|
4
|
+
* no reason to hand-roll a parser P2 will need to get right for writes
|
|
5
|
+
* anyway. Skill root is `~/.agents/skills` (Codex's own convention,
|
|
6
|
+
* confirmed in docs/research.md); `~/.codex/skills` is reported as a
|
|
7
|
+
* second skill root so `doctor`'s within-agent duplication check
|
|
8
|
+
* (src/commands/doctor.ts) can catch a physical copy living there —
|
|
9
|
+
* exactly the regression already found and fixed once in this project.
|
|
10
|
+
*/
|
|
11
|
+
import type { AgentSnapshot } from "../core/types.js";
|
|
12
|
+
/**
|
|
13
|
+
* P0-only heuristic extraction of the single top-level `instructions = "..."`
|
|
14
|
+
* key — not a TOML parser, and never feeds a write path. P2's writer uses a
|
|
15
|
+
* real TOML library (docs/research.md "Codex — three hard constraints" #3);
|
|
16
|
+
* this exists purely to display where Codex's instructions file lives.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readInstructionsPath(configTomlPath: string): string | undefined;
|
|
19
|
+
export interface ProbeOptions {
|
|
20
|
+
/** Spawn each configured stdio server for a live handshake. Off by
|
|
21
|
+
* default — see docs/architecture.md "MCP handshake probing is opt-in". */
|
|
22
|
+
probeMcp?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function probe(homeDir?: string, opts?: ProbeOptions): Promise<AgentSnapshot>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex probe: MCP inventory via `codex mcp list --json` (design.md D2),
|
|
3
|
+
* not TOML parsing — Codex already exposes this, so a read-only phase has
|
|
4
|
+
* no reason to hand-roll a parser P2 will need to get right for writes
|
|
5
|
+
* anyway. Skill root is `~/.agents/skills` (Codex's own convention,
|
|
6
|
+
* confirmed in docs/research.md); `~/.codex/skills` is reported as a
|
|
7
|
+
* second skill root so `doctor`'s within-agent duplication check
|
|
8
|
+
* (src/commands/doctor.ts) can catch a physical copy living there —
|
|
9
|
+
* exactly the regression already found and fixed once in this project.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { probeMcpServer } from "../lib/mcpProbe.js";
|
|
16
|
+
import { pathRef, resolveEnvRefs, scanSkillRoot } from "../lib/probeCommon.js";
|
|
17
|
+
/**
|
|
18
|
+
* P0-only heuristic extraction of the single top-level `instructions = "..."`
|
|
19
|
+
* key — not a TOML parser, and never feeds a write path. P2's writer uses a
|
|
20
|
+
* real TOML library (docs/research.md "Codex — three hard constraints" #3);
|
|
21
|
+
* this exists purely to display where Codex's instructions file lives.
|
|
22
|
+
*/
|
|
23
|
+
export function readInstructionsPath(configTomlPath) {
|
|
24
|
+
let content;
|
|
25
|
+
try {
|
|
26
|
+
content = readFileSync(configTomlPath, "utf-8");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return /^\s*instructions\s*=\s*"([^"]*)"/m.exec(content)?.[1];
|
|
32
|
+
}
|
|
33
|
+
export async function probe(homeDir = homedir(), opts = {}) {
|
|
34
|
+
const configTomlPath = join(homeDir, ".codex", "config.toml");
|
|
35
|
+
if (!existsSync(configTomlPath)) {
|
|
36
|
+
return { agent: "codex", present: false, skillRoots: [], mcpServers: [], diagnostics: [] };
|
|
37
|
+
}
|
|
38
|
+
const diagnostics = [];
|
|
39
|
+
let version;
|
|
40
|
+
try {
|
|
41
|
+
version = execFileSync("codex", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// config exists without the binary on PATH — unusual, still probeable
|
|
45
|
+
}
|
|
46
|
+
let mcpEntries = [];
|
|
47
|
+
try {
|
|
48
|
+
const raw = execFileSync("codex", ["mcp", "list", "--json"], { encoding: "utf-8", timeout: 5_000 });
|
|
49
|
+
mcpEntries = JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
diagnostics.push(`codex mcp list --json failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
const mcpServers = mcpEntries.map((entry) => ({
|
|
55
|
+
name: entry.name,
|
|
56
|
+
transport: entry.transport.type === "stdio" ? "stdio" : "http",
|
|
57
|
+
}));
|
|
58
|
+
if (opts.probeMcp) {
|
|
59
|
+
await Promise.all(mcpEntries.map(async (entry, index) => {
|
|
60
|
+
if (!entry.enabled || entry.transport.type !== "stdio" || !entry.transport.command)
|
|
61
|
+
return;
|
|
62
|
+
mcpServers[index].probe = await probeMcpServer({ transport: "stdio", command: entry.transport.command, args: entry.transport.args }, resolveEnvRefs(entry.transport.env_vars, process.env));
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
const primaryRoot = scanSkillRoot(join(homeDir, ".agents", "skills"));
|
|
66
|
+
const codexOwnRoot = scanSkillRoot(join(homeDir, ".codex", "skills"));
|
|
67
|
+
const skillRoots = [primaryRoot, codexOwnRoot].filter((r) => r !== undefined);
|
|
68
|
+
const instructionsPath = readInstructionsPath(configTomlPath);
|
|
69
|
+
return {
|
|
70
|
+
agent: "codex",
|
|
71
|
+
present: true,
|
|
72
|
+
version,
|
|
73
|
+
skillRoots,
|
|
74
|
+
mcpServers,
|
|
75
|
+
instructionsFile: instructionsPath ? pathRef(instructionsPath) : undefined,
|
|
76
|
+
diagnostics,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiro probe: same shape as Claude Code — `~/.kiro/settings/mcp.json`,
|
|
3
|
+
* `~/.kiro/skills`, `~/.kiro/steering/CLAUDE.md`. No subagent concept
|
|
4
|
+
* confirmed for Kiro (docs/research.md), so `subagentsDir` is never set.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentSnapshot } from "../core/types.js";
|
|
7
|
+
export interface ProbeOptions {
|
|
8
|
+
/** Spawn each configured stdio server for a live handshake. Off by
|
|
9
|
+
* default — see docs/architecture.md "MCP handshake probing is opt-in". */
|
|
10
|
+
probeMcp?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function probe(homeDir?: string, opts?: ProbeOptions): Promise<AgentSnapshot>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiro probe: same shape as Claude Code — `~/.kiro/settings/mcp.json`,
|
|
3
|
+
* `~/.kiro/skills`, `~/.kiro/steering/CLAUDE.md`. No subagent concept
|
|
4
|
+
* confirmed for Kiro (docs/research.md), so `subagentsDir` is never set.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { probeMcpServer } from "../lib/mcpProbe.js";
|
|
10
|
+
import { pathRef, readJsonFile, resolveEnvRefs, scanSkillRoot } from "../lib/probeCommon.js";
|
|
11
|
+
export async function probe(homeDir = homedir(), opts = {}) {
|
|
12
|
+
const mcpJsonPath = join(homeDir, ".kiro", "settings", "mcp.json");
|
|
13
|
+
const skillsPath = join(homeDir, ".kiro", "skills");
|
|
14
|
+
const steeringPath = join(homeDir, ".kiro", "steering", "CLAUDE.md");
|
|
15
|
+
const mcpJson = readJsonFile(mcpJsonPath);
|
|
16
|
+
const present = mcpJson !== undefined || pathRef(skillsPath) !== undefined || pathRef(steeringPath) !== undefined;
|
|
17
|
+
if (!present) {
|
|
18
|
+
return { agent: "kiro", present: false, skillRoots: [], mcpServers: [], diagnostics: [] };
|
|
19
|
+
}
|
|
20
|
+
let version;
|
|
21
|
+
try {
|
|
22
|
+
version = execFileSync("kiro-cli", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// binary not on PATH — config existing without it is still probeable
|
|
26
|
+
}
|
|
27
|
+
const mcpServers = Object.entries(mcpJson?.mcpServers ?? {}).map(([name, def]) => ({
|
|
28
|
+
name,
|
|
29
|
+
transport: def.url ? "http" : "stdio",
|
|
30
|
+
}));
|
|
31
|
+
if (opts.probeMcp) {
|
|
32
|
+
await Promise.all(Object.entries(mcpJson?.mcpServers ?? {}).map(async ([name, def], index) => {
|
|
33
|
+
if (def.url || !def.command)
|
|
34
|
+
return;
|
|
35
|
+
mcpServers[index].probe = await probeMcpServer({ transport: "stdio", command: def.command, args: def.args }, resolveEnvRefs(def.env, process.env));
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
const skillRoot = scanSkillRoot(skillsPath);
|
|
39
|
+
return {
|
|
40
|
+
agent: "kiro",
|
|
41
|
+
present: true,
|
|
42
|
+
version,
|
|
43
|
+
skillRoots: skillRoot ? [skillRoot] : [],
|
|
44
|
+
mcpServers,
|
|
45
|
+
instructionsFile: pathRef(steeringPath),
|
|
46
|
+
diagnostics: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi probe. Skill root and global instructions file are both under
|
|
3
|
+
* `~/.pi/agent` — confirmed by direct source read of
|
|
4
|
+
* `@earendil-works/pi-coding-agent`'s `dist/core/skills.js` (`loadSkills`)
|
|
5
|
+
* and `dist/core/resource-loader.js` (`loadContextFileFromDir(resolvedAgentDir)`),
|
|
6
|
+
* not by assumption. See design.md D5 (openspec/changes/trellis-doctor-p0).
|
|
7
|
+
*
|
|
8
|
+
* pi has no native MCP client (docs/research.md) — nothing to read or
|
|
9
|
+
* probe, so `mcpServers` is always empty here. That's real information
|
|
10
|
+
* (this is why P4 has to write a runtime bridge extension instead of
|
|
11
|
+
* generating config), not an omission.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentSnapshot } from "../core/types.js";
|
|
14
|
+
export declare function probe(homeDir?: string): Promise<AgentSnapshot>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi probe. Skill root and global instructions file are both under
|
|
3
|
+
* `~/.pi/agent` — confirmed by direct source read of
|
|
4
|
+
* `@earendil-works/pi-coding-agent`'s `dist/core/skills.js` (`loadSkills`)
|
|
5
|
+
* and `dist/core/resource-loader.js` (`loadContextFileFromDir(resolvedAgentDir)`),
|
|
6
|
+
* not by assumption. See design.md D5 (openspec/changes/trellis-doctor-p0).
|
|
7
|
+
*
|
|
8
|
+
* pi has no native MCP client (docs/research.md) — nothing to read or
|
|
9
|
+
* probe, so `mcpServers` is always empty here. That's real information
|
|
10
|
+
* (this is why P4 has to write a runtime bridge extension instead of
|
|
11
|
+
* generating config), not an omission.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { pathRef, scanSkillRoot } from "../lib/probeCommon.js";
|
|
18
|
+
/** Priority order pi itself checks (`loadContextFileFromDir`), first match wins. */
|
|
19
|
+
const INSTRUCTIONS_CANDIDATES = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
20
|
+
export async function probe(homeDir = homedir()) {
|
|
21
|
+
const agentDir = join(homeDir, ".pi", "agent");
|
|
22
|
+
const settingsPath = join(agentDir, "settings.json");
|
|
23
|
+
const skillsPath = join(agentDir, "skills");
|
|
24
|
+
const present = existsSync(settingsPath) || existsSync(skillsPath);
|
|
25
|
+
if (!present) {
|
|
26
|
+
return { agent: "pi", present: false, skillRoots: [], mcpServers: [], diagnostics: [] };
|
|
27
|
+
}
|
|
28
|
+
let version;
|
|
29
|
+
try {
|
|
30
|
+
version = execFileSync("pi", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// config exists without the binary on PATH — unusual, still probeable
|
|
34
|
+
}
|
|
35
|
+
const skillRoot = scanSkillRoot(skillsPath);
|
|
36
|
+
let instructionsFile;
|
|
37
|
+
for (const candidate of INSTRUCTIONS_CANDIDATES) {
|
|
38
|
+
const ref = pathRef(join(agentDir, candidate));
|
|
39
|
+
if (ref) {
|
|
40
|
+
instructionsFile = ref;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
agent: "pi",
|
|
46
|
+
present: true,
|
|
47
|
+
version,
|
|
48
|
+
skillRoots: skillRoot ? [skillRoot] : [],
|
|
49
|
+
mcpServers: [],
|
|
50
|
+
instructionsFile,
|
|
51
|
+
diagnostics: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
package/dist/sdk.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-trellis`'s public, read-only API surface (trellis-sdk-p5) — a
|
|
3
|
+
* stable contract for reading `~/.trellis/`'s canonical state without
|
|
4
|
+
* depending on the CLI. Deliberately narrow (design.md D1): everything
|
|
5
|
+
* here is canonical-source loading and its type surface, nothing from
|
|
6
|
+
* `src/adapters/*` or `src/commands/*` — those are how the four
|
|
7
|
+
* built-in integrations work, not something a third-party integration
|
|
8
|
+
* should reach into. A third party wanting Trellis-style sync for its
|
|
9
|
+
* own agent writes its own adapter against these types, the same way
|
|
10
|
+
* `src/adapters/pi.ts` does today.
|
|
11
|
+
*/
|
|
12
|
+
export { loadCanonicalSource } from "./core/canonical.js";
|
|
13
|
+
export { ALL_AGENTS, resolveScope } from "./core/types.js";
|
|
14
|
+
export type { AgentId, AgentProfile, CanonicalSource, HubConfig, McpConfig, McpServerDef, MemoryEntry, Scope, SecretsPolicy, SkillRef, Transport, } from "./core/types.js";
|
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-trellis`'s public, read-only API surface (trellis-sdk-p5) — a
|
|
3
|
+
* stable contract for reading `~/.trellis/`'s canonical state without
|
|
4
|
+
* depending on the CLI. Deliberately narrow (design.md D1): everything
|
|
5
|
+
* here is canonical-source loading and its type surface, nothing from
|
|
6
|
+
* `src/adapters/*` or `src/commands/*` — those are how the four
|
|
7
|
+
* built-in integrations work, not something a third-party integration
|
|
8
|
+
* should reach into. A third party wanting Trellis-style sync for its
|
|
9
|
+
* own agent writes its own adapter against these types, the same way
|
|
10
|
+
* `src/adapters/pi.ts` does today.
|
|
11
|
+
*/
|
|
12
|
+
export { loadCanonicalSource } from "./core/canonical.js";
|
|
13
|
+
export { ALL_AGENTS, resolveScope } from "./core/types.js";
|