pi-forge 0.0.0 → 1.1.4
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 +48 -4
- package/bin/pi-forge.mjs +37 -0
- package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
- package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
- package/dist/client/assets/index-B-529kgJ.css +32 -0
- package/dist/client/assets/index-BzKzxXFs.js +392 -0
- package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
- package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
- package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
- package/dist/client/icons/icon-192.png +0 -0
- package/dist/client/icons/icon-512.png +0 -0
- package/dist/client/icons/icon-maskable-512.png +0 -0
- package/dist/client/icons/icon.svg +9 -0
- package/dist/client/index.html +24 -0
- package/dist/client/manifest.webmanifest +1 -0
- package/dist/client/offline.html +142 -0
- package/dist/client/sw.js +3 -0
- package/dist/client/sw.js.map +1 -0
- package/dist/client/workbox-6d7155ed.js +3 -0
- package/dist/client/workbox-6d7155ed.js.map +1 -0
- package/dist/server/agent-resource-loader.js +126 -0
- package/dist/server/agent-resource-loader.js.map +1 -0
- package/dist/server/attachment-converters.js +96 -0
- package/dist/server/attachment-converters.js.map +1 -0
- package/dist/server/auth.js +209 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/compaction-history.js +106 -0
- package/dist/server/compaction-history.js.map +1 -0
- package/dist/server/concurrency.js +49 -0
- package/dist/server/concurrency.js.map +1 -0
- package/dist/server/config-export.js +220 -0
- package/dist/server/config-export.js.map +1 -0
- package/dist/server/config-manager.js +528 -0
- package/dist/server/config-manager.js.map +1 -0
- package/dist/server/config.js +326 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/conversion-worker.mjs +90 -0
- package/dist/server/diagnostics.js +137 -0
- package/dist/server/diagnostics.js.map +1 -0
- package/dist/server/extensions-discovery.js +147 -0
- package/dist/server/extensions-discovery.js.map +1 -0
- package/dist/server/file-manager.js +734 -0
- package/dist/server/file-manager.js.map +1 -0
- package/dist/server/file-references.js +215 -0
- package/dist/server/file-references.js.map +1 -0
- package/dist/server/file-searcher.js +385 -0
- package/dist/server/file-searcher.js.map +1 -0
- package/dist/server/git-runner.js +684 -0
- package/dist/server/git-runner.js.map +1 -0
- package/dist/server/index.js +468 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mcp/config.js +133 -0
- package/dist/server/mcp/config.js.map +1 -0
- package/dist/server/mcp/manager.js +351 -0
- package/dist/server/mcp/manager.js.map +1 -0
- package/dist/server/mcp/tool-bridge.js +173 -0
- package/dist/server/mcp/tool-bridge.js.map +1 -0
- package/dist/server/project-manager.js +301 -0
- package/dist/server/project-manager.js.map +1 -0
- package/dist/server/pty-manager.js +354 -0
- package/dist/server/pty-manager.js.map +1 -0
- package/dist/server/routes/_schemas.js +73 -0
- package/dist/server/routes/_schemas.js.map +1 -0
- package/dist/server/routes/auth.js +164 -0
- package/dist/server/routes/auth.js.map +1 -0
- package/dist/server/routes/config.js +1163 -0
- package/dist/server/routes/config.js.map +1 -0
- package/dist/server/routes/control.js +464 -0
- package/dist/server/routes/control.js.map +1 -0
- package/dist/server/routes/exec.js +217 -0
- package/dist/server/routes/exec.js.map +1 -0
- package/dist/server/routes/files.js +847 -0
- package/dist/server/routes/files.js.map +1 -0
- package/dist/server/routes/git.js +837 -0
- package/dist/server/routes/git.js.map +1 -0
- package/dist/server/routes/health.js +97 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/mcp.js +300 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/projects.js +259 -0
- package/dist/server/routes/projects.js.map +1 -0
- package/dist/server/routes/prompt.js +496 -0
- package/dist/server/routes/prompt.js.map +1 -0
- package/dist/server/routes/sessions.js +783 -0
- package/dist/server/routes/sessions.js.map +1 -0
- package/dist/server/routes/stream.js +69 -0
- package/dist/server/routes/stream.js.map +1 -0
- package/dist/server/routes/terminal.js +335 -0
- package/dist/server/routes/terminal.js.map +1 -0
- package/dist/server/session-registry.js +1197 -0
- package/dist/server/session-registry.js.map +1 -0
- package/dist/server/skill-overrides.js +151 -0
- package/dist/server/skill-overrides.js.map +1 -0
- package/dist/server/skills-export.js +257 -0
- package/dist/server/skills-export.js.map +1 -0
- package/dist/server/sse-bridge.js +220 -0
- package/dist/server/sse-bridge.js.map +1 -0
- package/dist/server/tool-overrides.js +277 -0
- package/dist/server/tool-overrides.js.map +1 -0
- package/dist/server/turn-diff-builder.js +280 -0
- package/dist/server/turn-diff-builder.js.map +1 -0
- package/package.json +53 -12
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
import { AuthProviderNotFoundError, liveProvidersListing, getAllSkillOverrides, listSkills, readAuthSummary, readModelsJsonRedacted, readSettings, removeApiKey, setSkillEnabled, SkillNotFoundError, updateSettings, writeApiKey, writeModelsJson, } from "../config-manager.js";
|
|
2
|
+
import { buildExportTar, importConfigFromBuffer, MAX_IMPORT_BYTES } from "../config-export.js";
|
|
3
|
+
import { buildSkillsExportTar, SkillsDirectoryEmptyError, importSkillsFromFiles, importSkillsFromTar, MAX_SKILLS_IMPORT_BYTES, } from "../skills-export.js";
|
|
4
|
+
import { ensureProjectLoaded as mcpEnsureProjectLoaded, getStatus as mcpGetStatus, } from "../mcp/manager.js";
|
|
5
|
+
import { BUILTIN_TOOL_NAMES } from "../session-registry.js";
|
|
6
|
+
import { discoverExtensionResources } from "../extensions-discovery.js";
|
|
7
|
+
import { getAllToolOverrides, getProjectToolState, isToolEffective, readToolOverrides, setProjectToolOverride, setToolEnabled, } from "../tool-overrides.js";
|
|
8
|
+
import { getProject } from "../project-manager.js";
|
|
9
|
+
import { errorSchema } from "./_schemas.js";
|
|
10
|
+
const modelsJsonSchema = {
|
|
11
|
+
type: "object",
|
|
12
|
+
required: ["providers"],
|
|
13
|
+
additionalProperties: true,
|
|
14
|
+
properties: {
|
|
15
|
+
// Loose validation: route accepts any shape under `providers` and lets
|
|
16
|
+
// the SDK reject malformed configs at load time. Tighter validation can
|
|
17
|
+
// come once the dev plan freezes the provider config schema.
|
|
18
|
+
providers: { type: "object", additionalProperties: true },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const settingsSchema = {
|
|
22
|
+
type: "object",
|
|
23
|
+
additionalProperties: true,
|
|
24
|
+
properties: {
|
|
25
|
+
// Each field accepts its real type OR null (which the handler interprets
|
|
26
|
+
// as "delete this key"). Loose typing on purpose — strict enums break the
|
|
27
|
+
// null-delete contract documented on the PUT route. The SDK validates
|
|
28
|
+
// settings.json shape on next read.
|
|
29
|
+
defaultProvider: { type: ["string", "null"] },
|
|
30
|
+
defaultModel: { type: ["string", "null"] },
|
|
31
|
+
defaultThinkingLevel: { type: ["string", "null"] },
|
|
32
|
+
steeringMode: { type: ["string", "null"] },
|
|
33
|
+
followUpMode: { type: ["string", "null"] },
|
|
34
|
+
skills: { type: ["array", "null"], items: { type: "string" } },
|
|
35
|
+
enableSkillCommands: { type: ["boolean", "null"] },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const authSummarySchema = {
|
|
39
|
+
type: "object",
|
|
40
|
+
required: ["providers"],
|
|
41
|
+
properties: {
|
|
42
|
+
providers: {
|
|
43
|
+
type: "object",
|
|
44
|
+
additionalProperties: {
|
|
45
|
+
type: "object",
|
|
46
|
+
required: ["configured"],
|
|
47
|
+
properties: {
|
|
48
|
+
configured: { type: "boolean" },
|
|
49
|
+
source: { type: "string" },
|
|
50
|
+
label: { type: "string" },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const providersListingSchema = {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["providers"],
|
|
59
|
+
properties: {
|
|
60
|
+
providers: {
|
|
61
|
+
type: "array",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
required: ["provider", "models"],
|
|
65
|
+
properties: {
|
|
66
|
+
provider: { type: "string" },
|
|
67
|
+
models: {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: {
|
|
70
|
+
type: "object",
|
|
71
|
+
required: [
|
|
72
|
+
"id",
|
|
73
|
+
"name",
|
|
74
|
+
"contextWindow",
|
|
75
|
+
"maxTokens",
|
|
76
|
+
"reasoning",
|
|
77
|
+
"input",
|
|
78
|
+
"hasAuth",
|
|
79
|
+
],
|
|
80
|
+
properties: {
|
|
81
|
+
id: { type: "string" },
|
|
82
|
+
name: { type: "string" },
|
|
83
|
+
contextWindow: { type: "integer" },
|
|
84
|
+
maxTokens: { type: "integer" },
|
|
85
|
+
reasoning: { type: "boolean" },
|
|
86
|
+
input: { type: "array", items: { type: "string" } },
|
|
87
|
+
hasAuth: { type: "boolean" },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const skillSchema = {
|
|
97
|
+
type: "object",
|
|
98
|
+
required: [
|
|
99
|
+
"name",
|
|
100
|
+
"description",
|
|
101
|
+
"source",
|
|
102
|
+
"filePath",
|
|
103
|
+
"enabled",
|
|
104
|
+
"effective",
|
|
105
|
+
"disableModelInvocation",
|
|
106
|
+
],
|
|
107
|
+
properties: {
|
|
108
|
+
name: { type: "string" },
|
|
109
|
+
description: { type: "string" },
|
|
110
|
+
source: { type: "string", enum: ["global", "project", "extension"] },
|
|
111
|
+
filePath: { type: "string" },
|
|
112
|
+
/** Identifier of the package that contributed this skill (only when source === "extension"). */
|
|
113
|
+
extensionPath: { type: "string" },
|
|
114
|
+
enabled: { type: "boolean" },
|
|
115
|
+
/** Tri-state per-project override; absent = inherit from global. */
|
|
116
|
+
projectOverride: { type: "string", enum: ["enabled", "disabled"] },
|
|
117
|
+
/** Resolved state the agent in the queried project would see. */
|
|
118
|
+
effective: { type: "boolean" },
|
|
119
|
+
disableModelInvocation: { type: "boolean" },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
function internalError(reply, err) {
|
|
123
|
+
reply.log.error({ err }, "config route error");
|
|
124
|
+
return reply.code(500).send({ error: "internal_error" });
|
|
125
|
+
}
|
|
126
|
+
export const configRoutes = async (fastify) => {
|
|
127
|
+
// ---------------------- models.json ----------------------
|
|
128
|
+
fastify.get("/config/models", {
|
|
129
|
+
schema: {
|
|
130
|
+
description: "Read `models.json` (custom provider configurations). Inline `apiKey` " +
|
|
131
|
+
"and `apiKeyCommand` fields are returned as `***REDACTED***` so the " +
|
|
132
|
+
"raw secret never leaves the server. The persisted file is unchanged " +
|
|
133
|
+
"— PUT /config/models takes the actual values; the redaction is on " +
|
|
134
|
+
"the read path only.",
|
|
135
|
+
tags: ["config"],
|
|
136
|
+
response: { 200: modelsJsonSchema, 500: errorSchema },
|
|
137
|
+
},
|
|
138
|
+
}, async (_req, reply) => {
|
|
139
|
+
try {
|
|
140
|
+
return await readModelsJsonRedacted();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return internalError(reply, err);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
fastify.put("/config/models", {
|
|
147
|
+
schema: {
|
|
148
|
+
description: "Replace `models.json` atomically. The SDK validates the structure " +
|
|
149
|
+
"on the next session creation; malformed configs are rejected then.",
|
|
150
|
+
tags: ["config"],
|
|
151
|
+
body: modelsJsonSchema,
|
|
152
|
+
response: { 200: modelsJsonSchema, 400: errorSchema, 500: errorSchema },
|
|
153
|
+
},
|
|
154
|
+
}, async (req, reply) => {
|
|
155
|
+
try {
|
|
156
|
+
await writeModelsJson(req.body);
|
|
157
|
+
return req.body;
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return internalError(reply, err);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// ---------------------- live providers ----------------------
|
|
164
|
+
fastify.get("/config/providers", {
|
|
165
|
+
schema: {
|
|
166
|
+
description: "Live provider + model listing assembled from the SDK's ModelRegistry " +
|
|
167
|
+
"(combines built-in models with anything in `models.json`). Each model " +
|
|
168
|
+
"carries a `hasAuth` boolean so the UI can dim entries with no key.",
|
|
169
|
+
tags: ["config"],
|
|
170
|
+
response: { 200: providersListingSchema, 500: errorSchema },
|
|
171
|
+
},
|
|
172
|
+
}, async (_req, reply) => {
|
|
173
|
+
try {
|
|
174
|
+
return await liveProvidersListing();
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return internalError(reply, err);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// ---------------------- settings.json ----------------------
|
|
181
|
+
fastify.get("/config/settings", {
|
|
182
|
+
schema: {
|
|
183
|
+
description: "Read `settings.json` (default provider/model, modes, skills list, etc).",
|
|
184
|
+
tags: ["config"],
|
|
185
|
+
response: { 200: settingsSchema, 500: errorSchema },
|
|
186
|
+
},
|
|
187
|
+
}, async (_req, reply) => {
|
|
188
|
+
try {
|
|
189
|
+
return await readSettings();
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
return internalError(reply, err);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
fastify.put("/config/settings", {
|
|
196
|
+
schema: {
|
|
197
|
+
description: "Partial-merge update for `settings.json`. Sending `null` for any key " +
|
|
198
|
+
"deletes it; other values overwrite. Atomic write.",
|
|
199
|
+
tags: ["config"],
|
|
200
|
+
body: settingsSchema,
|
|
201
|
+
response: { 200: settingsSchema, 400: errorSchema, 500: errorSchema },
|
|
202
|
+
},
|
|
203
|
+
}, async (req, reply) => {
|
|
204
|
+
try {
|
|
205
|
+
return await updateSettings(req.body);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return internalError(reply, err);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
// ---------------------- auth.json (presence only) ----------------------
|
|
212
|
+
fastify.get("/config/auth", {
|
|
213
|
+
schema: {
|
|
214
|
+
description: "Provider credential PRESENCE map. Never includes actual key values — " +
|
|
215
|
+
"the response shape is presence + source + label only.",
|
|
216
|
+
tags: ["config"],
|
|
217
|
+
response: { 200: authSummarySchema, 500: errorSchema },
|
|
218
|
+
},
|
|
219
|
+
}, async (_req, reply) => {
|
|
220
|
+
try {
|
|
221
|
+
return readAuthSummary();
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
return internalError(reply, err);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
fastify.put("/config/auth/:provider", {
|
|
228
|
+
schema: {
|
|
229
|
+
description: "Store an API key for a provider. The key is written to `auth.json` " +
|
|
230
|
+
"(file-locked via the SDK); existing keys for OTHER providers are " +
|
|
231
|
+
"untouched. Body: `{ apiKey }`.",
|
|
232
|
+
tags: ["config"],
|
|
233
|
+
params: {
|
|
234
|
+
type: "object",
|
|
235
|
+
required: ["provider"],
|
|
236
|
+
properties: { provider: { type: "string", minLength: 1 } },
|
|
237
|
+
},
|
|
238
|
+
body: {
|
|
239
|
+
type: "object",
|
|
240
|
+
required: ["apiKey"],
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
properties: { apiKey: { type: "string", minLength: 1 } },
|
|
243
|
+
},
|
|
244
|
+
response: {
|
|
245
|
+
200: {
|
|
246
|
+
type: "object",
|
|
247
|
+
required: ["provider", "configured"],
|
|
248
|
+
properties: {
|
|
249
|
+
provider: { type: "string" },
|
|
250
|
+
configured: { type: "boolean", const: true },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
400: errorSchema,
|
|
254
|
+
500: errorSchema,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
}, async (req, reply) => {
|
|
258
|
+
try {
|
|
259
|
+
writeApiKey(req.params.provider, req.body.apiKey);
|
|
260
|
+
return { provider: req.params.provider, configured: true };
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
return internalError(reply, err);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
fastify.delete("/config/auth/:provider", {
|
|
267
|
+
schema: {
|
|
268
|
+
description: "Remove credentials for a provider.",
|
|
269
|
+
tags: ["config"],
|
|
270
|
+
params: {
|
|
271
|
+
type: "object",
|
|
272
|
+
required: ["provider"],
|
|
273
|
+
properties: { provider: { type: "string", minLength: 1 } },
|
|
274
|
+
},
|
|
275
|
+
response: { 204: { type: "null" }, 404: errorSchema, 500: errorSchema },
|
|
276
|
+
},
|
|
277
|
+
}, async (req, reply) => {
|
|
278
|
+
try {
|
|
279
|
+
removeApiKey(req.params.provider);
|
|
280
|
+
return reply.code(204).send();
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
if (err instanceof AuthProviderNotFoundError) {
|
|
284
|
+
return reply.code(404).send({ error: "auth_provider_not_found" });
|
|
285
|
+
}
|
|
286
|
+
return internalError(reply, err);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
// ---------------------- skills ----------------------
|
|
290
|
+
fastify.get("/config/skills", {
|
|
291
|
+
schema: {
|
|
292
|
+
description: "List skills discovered for a project. Skills come from two sources: " +
|
|
293
|
+
"the global `~/.pi/agent/skills/` and the project-local `.pi/skills/`. " +
|
|
294
|
+
"Each skill carries `enabled` reflecting whether it's listed in " +
|
|
295
|
+
"`settings.skills`. Required: `?projectId=`.",
|
|
296
|
+
tags: ["config"],
|
|
297
|
+
querystring: {
|
|
298
|
+
type: "object",
|
|
299
|
+
required: ["projectId"],
|
|
300
|
+
properties: { projectId: { type: "string" } },
|
|
301
|
+
},
|
|
302
|
+
response: {
|
|
303
|
+
200: {
|
|
304
|
+
type: "object",
|
|
305
|
+
required: ["skills"],
|
|
306
|
+
properties: { skills: { type: "array", items: skillSchema } },
|
|
307
|
+
},
|
|
308
|
+
404: errorSchema,
|
|
309
|
+
500: errorSchema,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
}, async (req, reply) => {
|
|
313
|
+
const project = await getProject(req.query.projectId);
|
|
314
|
+
if (project === undefined) {
|
|
315
|
+
return reply.code(404).send({ error: "project_not_found" });
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const skills = await listSkills(project.path, project.id);
|
|
319
|
+
return { skills };
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
return internalError(reply, err);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
// Cascade view: every per-project override across every project,
|
|
326
|
+
// for the Settings UI's per-skill expand-and-show-all-projects
|
|
327
|
+
// affordance. Single small JSON file on disk; one fetch per
|
|
328
|
+
// tab-open is fine.
|
|
329
|
+
fastify.get("/config/skills/overrides", {
|
|
330
|
+
schema: {
|
|
331
|
+
description: "All per-project skill overrides across all projects. Returns " +
|
|
332
|
+
"`{ projects: { <projectId>: { enable: [...], disable: [...] } } }`. " +
|
|
333
|
+
"Absent project keys mean 'no overrides defined' (the project " +
|
|
334
|
+
"inherits everything from global).",
|
|
335
|
+
tags: ["config"],
|
|
336
|
+
response: {
|
|
337
|
+
200: {
|
|
338
|
+
type: "object",
|
|
339
|
+
required: ["projects"],
|
|
340
|
+
properties: {
|
|
341
|
+
projects: {
|
|
342
|
+
type: "object",
|
|
343
|
+
additionalProperties: {
|
|
344
|
+
type: "object",
|
|
345
|
+
required: ["enable", "disable"],
|
|
346
|
+
properties: {
|
|
347
|
+
enable: { type: "array", items: { type: "string" } },
|
|
348
|
+
disable: { type: "array", items: { type: "string" } },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
500: errorSchema,
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
}, async (_req, reply) => {
|
|
358
|
+
try {
|
|
359
|
+
return await getAllSkillOverrides();
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
return internalError(reply, err);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
fastify.put("/config/skills/:name/enabled", {
|
|
366
|
+
schema: {
|
|
367
|
+
description: "Toggle a skill's enabled state. Default scope=`global` mutates " +
|
|
368
|
+
"pi's `settings.skills` (canonical enable/disable list shared with " +
|
|
369
|
+
"the pi TUI). scope=`project` writes to the pi-forge-private " +
|
|
370
|
+
"overrides file at `${FORGE_DATA_DIR}/skills-overrides.json` " +
|
|
371
|
+
"for the project named in `?projectId=`. Project-scope overrides " +
|
|
372
|
+
"follow tri-state semantics: `enabled` adds, `disabled` removes; " +
|
|
373
|
+
"absence (cleared via DELETE) inherits from global. Skill changes " +
|
|
374
|
+
"apply on the NEXT session created in the affected project — live " +
|
|
375
|
+
"sessions keep the skill set they booted with.",
|
|
376
|
+
tags: ["config"],
|
|
377
|
+
params: {
|
|
378
|
+
type: "object",
|
|
379
|
+
required: ["name"],
|
|
380
|
+
properties: { name: { type: "string", minLength: 1 } },
|
|
381
|
+
},
|
|
382
|
+
querystring: {
|
|
383
|
+
type: "object",
|
|
384
|
+
required: ["projectId"],
|
|
385
|
+
properties: { projectId: { type: "string" } },
|
|
386
|
+
},
|
|
387
|
+
body: {
|
|
388
|
+
type: "object",
|
|
389
|
+
required: ["enabled"],
|
|
390
|
+
additionalProperties: false,
|
|
391
|
+
properties: {
|
|
392
|
+
enabled: { type: "boolean" },
|
|
393
|
+
scope: { type: "string", enum: ["global", "project"] },
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
response: {
|
|
397
|
+
200: {
|
|
398
|
+
type: "object",
|
|
399
|
+
required: ["skills"],
|
|
400
|
+
properties: { skills: { type: "array", items: skillSchema } },
|
|
401
|
+
},
|
|
402
|
+
404: errorSchema,
|
|
403
|
+
500: errorSchema,
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
}, async (req, reply) => {
|
|
407
|
+
const project = await getProject(req.query.projectId);
|
|
408
|
+
if (project === undefined) {
|
|
409
|
+
return reply.code(404).send({ error: "project_not_found" });
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const scope = req.body.scope ?? "global";
|
|
413
|
+
const skills = await setSkillEnabled(req.params.name, req.body.enabled, project.path, {
|
|
414
|
+
scope,
|
|
415
|
+
projectId: project.id,
|
|
416
|
+
});
|
|
417
|
+
return { skills };
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
if (err instanceof SkillNotFoundError) {
|
|
421
|
+
return reply.code(404).send({ error: "skill_not_found" });
|
|
422
|
+
}
|
|
423
|
+
return internalError(reply, err);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
fastify.delete("/config/skills/:name/enabled", {
|
|
427
|
+
schema: {
|
|
428
|
+
description: "Clear a skill's PROJECT override (= return it to inherit from " +
|
|
429
|
+
"global). Does not affect pi's settings.skills. Use the PUT " +
|
|
430
|
+
"endpoint to change global state.",
|
|
431
|
+
tags: ["config"],
|
|
432
|
+
params: {
|
|
433
|
+
type: "object",
|
|
434
|
+
required: ["name"],
|
|
435
|
+
properties: { name: { type: "string", minLength: 1 } },
|
|
436
|
+
},
|
|
437
|
+
querystring: {
|
|
438
|
+
type: "object",
|
|
439
|
+
required: ["projectId"],
|
|
440
|
+
properties: { projectId: { type: "string" } },
|
|
441
|
+
},
|
|
442
|
+
response: {
|
|
443
|
+
200: {
|
|
444
|
+
type: "object",
|
|
445
|
+
required: ["skills"],
|
|
446
|
+
properties: { skills: { type: "array", items: skillSchema } },
|
|
447
|
+
},
|
|
448
|
+
404: errorSchema,
|
|
449
|
+
500: errorSchema,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
}, async (req, reply) => {
|
|
453
|
+
const project = await getProject(req.query.projectId);
|
|
454
|
+
if (project === undefined) {
|
|
455
|
+
return reply.code(404).send({ error: "project_not_found" });
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const skills = await setSkillEnabled(req.params.name, undefined, project.path, {
|
|
459
|
+
scope: "project",
|
|
460
|
+
projectId: project.id,
|
|
461
|
+
});
|
|
462
|
+
return { skills };
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
if (err instanceof SkillNotFoundError) {
|
|
466
|
+
return reply.code(404).send({ error: "skill_not_found" });
|
|
467
|
+
}
|
|
468
|
+
return internalError(reply, err);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
// ---------------------- export / import ----------------------
|
|
472
|
+
// Two routes that round-trip the pi-forge's portable config
|
|
473
|
+
// (mcp.json + settings.json + models.json — see config-export.ts
|
|
474
|
+
// header for what's in and what's out).
|
|
475
|
+
fastify.get("/config/export", {
|
|
476
|
+
schema: {
|
|
477
|
+
description: "Stream a `.tar.gz` of the portable pi-forge config: " +
|
|
478
|
+
"`mcp.json`, `settings.json`, and `models.json`. Excludes " +
|
|
479
|
+
"`auth.json` (provider keys / OAuth tokens) and any " +
|
|
480
|
+
"installation-bound files (jwt-secret, password-hash). " +
|
|
481
|
+
"The header `X-Pi-Forge-Files` lists the names actually " +
|
|
482
|
+
"included so a client can warn when a file was missing on " +
|
|
483
|
+
"disk and therefore omitted from the export.",
|
|
484
|
+
tags: ["config"],
|
|
485
|
+
response: {
|
|
486
|
+
200: {
|
|
487
|
+
description: "gzip-compressed tar of the included files",
|
|
488
|
+
type: "string",
|
|
489
|
+
format: "binary",
|
|
490
|
+
},
|
|
491
|
+
500: errorSchema,
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
}, async (_req, reply) => {
|
|
495
|
+
try {
|
|
496
|
+
const { files, stream } = await buildExportTar();
|
|
497
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
498
|
+
reply
|
|
499
|
+
.header("Content-Type", "application/gzip")
|
|
500
|
+
.header("Content-Disposition", `attachment; filename="pi-forge-config-${ts}.tar.gz"`)
|
|
501
|
+
.header("X-Pi-Forge-Files", files.join(","));
|
|
502
|
+
return reply.send(stream);
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
return internalError(reply, err);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
fastify.post("/config/import", {
|
|
509
|
+
schema: {
|
|
510
|
+
description: "Restore a `.tar.gz` previously produced by `/config/export`. " +
|
|
511
|
+
"The archive must contain only the three top-level files " +
|
|
512
|
+
"`mcp.json`, `settings.json`, `models.json` — anything else " +
|
|
513
|
+
"is reported in `skipped`. Each accepted file is parsed as " +
|
|
514
|
+
"JSON; ALL files must validate before ANY are written. " +
|
|
515
|
+
"Imported files land atomically (`.tmp` + rename). " +
|
|
516
|
+
"**Provider auth is NOT included in exports** — re-authenticate " +
|
|
517
|
+
"providers via the Auth settings page after import.",
|
|
518
|
+
tags: ["config"],
|
|
519
|
+
consumes: ["multipart/form-data"],
|
|
520
|
+
response: {
|
|
521
|
+
200: {
|
|
522
|
+
type: "object",
|
|
523
|
+
required: ["imported", "skipped", "errors"],
|
|
524
|
+
properties: {
|
|
525
|
+
imported: { type: "array", items: { type: "string" } },
|
|
526
|
+
skipped: { type: "array", items: { type: "string" } },
|
|
527
|
+
errors: {
|
|
528
|
+
type: "array",
|
|
529
|
+
items: {
|
|
530
|
+
type: "object",
|
|
531
|
+
required: ["file", "reason"],
|
|
532
|
+
properties: {
|
|
533
|
+
file: { type: "string" },
|
|
534
|
+
reason: { type: "string" },
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
400: errorSchema,
|
|
541
|
+
413: errorSchema,
|
|
542
|
+
500: errorSchema,
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
}, async (req, reply) => {
|
|
546
|
+
// Single multipart file expected. Anything beyond the first is
|
|
547
|
+
// ignored — the import contract is "one tar.gz per request."
|
|
548
|
+
let buf;
|
|
549
|
+
try {
|
|
550
|
+
const file = await req.file({ limits: { fileSize: MAX_IMPORT_BYTES } });
|
|
551
|
+
if (file === undefined) {
|
|
552
|
+
return reply.code(400).send({ error: "no_file" });
|
|
553
|
+
}
|
|
554
|
+
buf = await file.toBuffer();
|
|
555
|
+
// toBuffer caps silently at the size limit; detect via the
|
|
556
|
+
// `truncated` flag the multipart stream sets, otherwise the
|
|
557
|
+
// user gets a confused "tar parse error" instead of the right
|
|
558
|
+
// 413 with a clear message.
|
|
559
|
+
if (file.file.truncated) {
|
|
560
|
+
return reply.code(413).send({
|
|
561
|
+
error: "file_too_large",
|
|
562
|
+
message: `import archive exceeds ${MAX_IMPORT_BYTES} bytes`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
return reply.code(400).send({
|
|
568
|
+
error: "invalid_multipart",
|
|
569
|
+
message: err instanceof Error ? err.message : String(err),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const summary = await importConfigFromBuffer(buf);
|
|
574
|
+
return summary;
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
return internalError(reply, err);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
// ---------------------- skills export / import ----------------------
|
|
581
|
+
// Skills tree export. Streams a tar.gz of every file under
|
|
582
|
+
// `${piConfigDir}/skills/` — single-file skills (`<name>.md`) and
|
|
583
|
+
// directory skills (`<name>/SKILL.md` plus assets) round-trip
|
|
584
|
+
// verbatim. When the skills directory is missing or empty, the
|
|
585
|
+
// route returns 409 with a stable code so the UI can show "no
|
|
586
|
+
// skills to export" instead of triggering a download — see the
|
|
587
|
+
// SkillsDirectoryEmptyError class in skills-export.ts for why we
|
|
588
|
+
// don't ship an empty archive.
|
|
589
|
+
fastify.get("/config/skills/export", {
|
|
590
|
+
schema: {
|
|
591
|
+
description: "Stream a `.tar.gz` of every file under `${piConfigDir}/skills/`. " +
|
|
592
|
+
"Single-file (`<name>.md`) and directory skills (`<name>/SKILL.md` + " +
|
|
593
|
+
"assets) both round-trip. Returns 409 `skills_directory_empty` when " +
|
|
594
|
+
"the skills tree is missing or contains no files.",
|
|
595
|
+
tags: ["config"],
|
|
596
|
+
response: {
|
|
597
|
+
200: {
|
|
598
|
+
description: "gzip-compressed tar of the skills directory contents",
|
|
599
|
+
type: "string",
|
|
600
|
+
format: "binary",
|
|
601
|
+
},
|
|
602
|
+
409: errorSchema,
|
|
603
|
+
500: errorSchema,
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
}, async (_req, reply) => {
|
|
607
|
+
try {
|
|
608
|
+
const { fileCount, stream } = await buildSkillsExportTar();
|
|
609
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
610
|
+
reply
|
|
611
|
+
.header("Content-Type", "application/gzip")
|
|
612
|
+
.header("Content-Disposition", `attachment; filename="pi-forge-skills-${ts}.tar.gz"`)
|
|
613
|
+
.header("X-Pi-Forge-File-Count", String(fileCount));
|
|
614
|
+
return reply.send(stream);
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
if (err instanceof SkillsDirectoryEmptyError) {
|
|
618
|
+
return reply.code(409).send({ error: "skills_directory_empty" });
|
|
619
|
+
}
|
|
620
|
+
return internalError(reply, err);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
// Skills tree import. Two shapes accepted:
|
|
624
|
+
// 1. A single multipart file part — server treats it as a tar.gz
|
|
625
|
+
// and delegates to `importSkillsFromTar`.
|
|
626
|
+
// 2. Multiple multipart file parts — typical of an
|
|
627
|
+
// `<input webkitdirectory>` folder pick. Each part's `filename`
|
|
628
|
+
// carries the relative path inside the picked folder; server
|
|
629
|
+
// writes each into the skills tree after the path-safety
|
|
630
|
+
// filter.
|
|
631
|
+
// The route auto-detects: if exactly one part is present AND its
|
|
632
|
+
// filename ends in `.tar.gz` / `.tgz`, it's treated as a tar; in any
|
|
633
|
+
// other case the parts are imported as discrete files.
|
|
634
|
+
fastify.post("/config/skills/import", {
|
|
635
|
+
schema: {
|
|
636
|
+
description: "Restore a skills tar.gz OR upload a folder of skill files. " +
|
|
637
|
+
"Tar.gz path: must contain only relative paths under the skills " +
|
|
638
|
+
"directory; absolute paths and `..` traversal are rejected. " +
|
|
639
|
+
"Folder upload path: each multipart `filename` is treated as a " +
|
|
640
|
+
"relative path inside the skills tree (same safety filter). " +
|
|
641
|
+
"Existing files at colliding paths are overwritten.",
|
|
642
|
+
tags: ["config"],
|
|
643
|
+
consumes: ["multipart/form-data"],
|
|
644
|
+
response: {
|
|
645
|
+
200: {
|
|
646
|
+
type: "object",
|
|
647
|
+
required: ["imported", "skipped"],
|
|
648
|
+
properties: {
|
|
649
|
+
imported: { type: "array", items: { type: "string" } },
|
|
650
|
+
skipped: {
|
|
651
|
+
type: "array",
|
|
652
|
+
items: {
|
|
653
|
+
type: "object",
|
|
654
|
+
required: ["name", "reason"],
|
|
655
|
+
properties: {
|
|
656
|
+
name: { type: "string" },
|
|
657
|
+
reason: { type: "string" },
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
400: errorSchema,
|
|
664
|
+
413: errorSchema,
|
|
665
|
+
500: errorSchema,
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
}, async (req, reply) => {
|
|
669
|
+
// Collect every multipart file part up front. We need to know the
|
|
670
|
+
// count + filenames before deciding tar-vs-folder, so we buffer
|
|
671
|
+
// each part's bytes (capped per-part by the multipart limit) and
|
|
672
|
+
// then dispatch to the right importer.
|
|
673
|
+
const parts = [];
|
|
674
|
+
try {
|
|
675
|
+
const iter = req.files({ limits: { fileSize: MAX_SKILLS_IMPORT_BYTES } });
|
|
676
|
+
for await (const f of iter) {
|
|
677
|
+
if (f.file.truncated) {
|
|
678
|
+
return reply.code(413).send({
|
|
679
|
+
error: "file_too_large",
|
|
680
|
+
message: `part "${f.filename}" exceeds ${MAX_SKILLS_IMPORT_BYTES} bytes`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
const buf = await f.toBuffer();
|
|
684
|
+
if (f.file.truncated) {
|
|
685
|
+
return reply.code(413).send({
|
|
686
|
+
error: "file_too_large",
|
|
687
|
+
message: `part "${f.filename}" exceeds ${MAX_SKILLS_IMPORT_BYTES} bytes`,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
parts.push({ filename: f.filename, buffer: buf });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
return reply.code(400).send({
|
|
695
|
+
error: "invalid_multipart",
|
|
696
|
+
message: err instanceof Error ? err.message : String(err),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (parts.length === 0) {
|
|
700
|
+
return reply.code(400).send({ error: "no_file" });
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
const isTarball = parts.length === 1 &&
|
|
704
|
+
(parts[0].filename.endsWith(".tar.gz") || parts[0].filename.endsWith(".tgz"));
|
|
705
|
+
const summary = isTarball
|
|
706
|
+
? await importSkillsFromTar(parts[0].buffer)
|
|
707
|
+
: await importSkillsFromFiles(parts);
|
|
708
|
+
return summary;
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
return internalError(reply, err);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
// ---------------------- per-tool overrides ----------------------
|
|
715
|
+
// Surface the unified tool view (builtins + per-MCP-server tools)
|
|
716
|
+
// and a single toggle endpoint. The agent-side filter that applies
|
|
717
|
+
// these overrides lives in `session-registry.buildToolsAllowlist`
|
|
718
|
+
// and runs at every `createAgentSession` site — see that function
|
|
719
|
+
// for the runtime semantics. This route pair is just the operator
|
|
720
|
+
// interface.
|
|
721
|
+
// Cascade view: every per-project tool override across every
|
|
722
|
+
// project, used by the Tools/MCP tabs' "+ Add override for…"
|
|
723
|
+
// affordance. Mirrors the skills cascade endpoint at
|
|
724
|
+
// /config/skills/overrides — same shape, same posture (single
|
|
725
|
+
// small JSON file, one fetch per tab open is fine).
|
|
726
|
+
fastify.get("/config/tools/overrides", {
|
|
727
|
+
schema: {
|
|
728
|
+
description: "All per-project tool overrides across all projects. Returns " +
|
|
729
|
+
"`{ projects: { <projectId>: { builtin: { enable, disable }, " +
|
|
730
|
+
"mcp: { enable, disable }, extension: { enable, disable } } } }`. " +
|
|
731
|
+
"Absent project keys mean 'no overrides defined' (the project " +
|
|
732
|
+
"inherits from global).",
|
|
733
|
+
tags: ["config"],
|
|
734
|
+
response: {
|
|
735
|
+
200: {
|
|
736
|
+
type: "object",
|
|
737
|
+
required: ["projects"],
|
|
738
|
+
properties: {
|
|
739
|
+
projects: {
|
|
740
|
+
type: "object",
|
|
741
|
+
additionalProperties: {
|
|
742
|
+
type: "object",
|
|
743
|
+
required: ["builtin", "mcp", "extension"],
|
|
744
|
+
properties: {
|
|
745
|
+
builtin: {
|
|
746
|
+
type: "object",
|
|
747
|
+
required: ["enable", "disable"],
|
|
748
|
+
properties: {
|
|
749
|
+
enable: { type: "array", items: { type: "string" } },
|
|
750
|
+
disable: { type: "array", items: { type: "string" } },
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
mcp: {
|
|
754
|
+
type: "object",
|
|
755
|
+
required: ["enable", "disable"],
|
|
756
|
+
properties: {
|
|
757
|
+
enable: { type: "array", items: { type: "string" } },
|
|
758
|
+
disable: { type: "array", items: { type: "string" } },
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
extension: {
|
|
762
|
+
type: "object",
|
|
763
|
+
required: ["enable", "disable"],
|
|
764
|
+
properties: {
|
|
765
|
+
enable: { type: "array", items: { type: "string" } },
|
|
766
|
+
disable: { type: "array", items: { type: "string" } },
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
500: errorSchema,
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
}, async (_req, reply) => {
|
|
778
|
+
try {
|
|
779
|
+
return await getAllToolOverrides();
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
return internalError(reply, err);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
fastify.get("/config/tools", {
|
|
786
|
+
schema: {
|
|
787
|
+
description: "List every tool the agent could see, with its current " +
|
|
788
|
+
"enable/disable state. Three families: `builtin` (pi's " +
|
|
789
|
+
"shipped coding tools — read, bash, edit, write, grep, " +
|
|
790
|
+
"find, ls), `mcp` (one entry per connected MCP server, " +
|
|
791
|
+
"each with its tool list), and `extension` (one entry " +
|
|
792
|
+
"per pi extension that registers tools, grouped by the " +
|
|
793
|
+
"extension's path). When `?projectId=` is provided, " +
|
|
794
|
+
"project-scoped MCP servers are included alongside global " +
|
|
795
|
+
"ones; the project-scope server-name shadowing rule from " +
|
|
796
|
+
"`mcp/manager.customToolsForProject` applies. Tool changes " +
|
|
797
|
+
"apply on the NEXT session created — live sessions keep " +
|
|
798
|
+
"the tool set they booted with.",
|
|
799
|
+
tags: ["config"],
|
|
800
|
+
querystring: {
|
|
801
|
+
type: "object",
|
|
802
|
+
properties: { projectId: { type: "string" } },
|
|
803
|
+
},
|
|
804
|
+
response: {
|
|
805
|
+
200: {
|
|
806
|
+
type: "object",
|
|
807
|
+
required: ["builtin", "mcp", "extension"],
|
|
808
|
+
properties: {
|
|
809
|
+
builtin: {
|
|
810
|
+
type: "array",
|
|
811
|
+
items: {
|
|
812
|
+
type: "object",
|
|
813
|
+
required: ["name", "description", "enabled", "globalEnabled"],
|
|
814
|
+
properties: {
|
|
815
|
+
name: { type: "string" },
|
|
816
|
+
description: { type: "string" },
|
|
817
|
+
/** Effective state for the active project (or
|
|
818
|
+
* global state when no projectId given). */
|
|
819
|
+
enabled: { type: "boolean" },
|
|
820
|
+
/** Underlying global state, regardless of any
|
|
821
|
+
* project override. The UI uses this to render
|
|
822
|
+
* the "Global: enabled" badge alongside the
|
|
823
|
+
* per-project tri-state. */
|
|
824
|
+
globalEnabled: { type: "boolean" },
|
|
825
|
+
/** Tri-state per-project override (absent = inherit). */
|
|
826
|
+
projectOverride: { type: "string", enum: ["enabled", "disabled"] },
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
mcp: {
|
|
831
|
+
type: "array",
|
|
832
|
+
items: {
|
|
833
|
+
type: "object",
|
|
834
|
+
required: ["server", "scope", "enabled", "state", "tools"],
|
|
835
|
+
properties: {
|
|
836
|
+
server: { type: "string" },
|
|
837
|
+
scope: { type: "string", enum: ["global", "project"] },
|
|
838
|
+
projectId: { type: "string" },
|
|
839
|
+
enabled: { type: "boolean" },
|
|
840
|
+
state: { type: "string" },
|
|
841
|
+
lastError: { type: "string" },
|
|
842
|
+
tools: {
|
|
843
|
+
type: "array",
|
|
844
|
+
items: {
|
|
845
|
+
type: "object",
|
|
846
|
+
required: ["name", "shortName", "description", "enabled", "globalEnabled"],
|
|
847
|
+
properties: {
|
|
848
|
+
name: { type: "string" },
|
|
849
|
+
shortName: { type: "string" },
|
|
850
|
+
description: { type: "string" },
|
|
851
|
+
enabled: { type: "boolean" },
|
|
852
|
+
globalEnabled: { type: "boolean" },
|
|
853
|
+
projectOverride: { type: "string", enum: ["enabled", "disabled"] },
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
extension: {
|
|
861
|
+
type: "array",
|
|
862
|
+
items: {
|
|
863
|
+
type: "object",
|
|
864
|
+
required: ["packageSource", "tools"],
|
|
865
|
+
properties: {
|
|
866
|
+
/** Package identifier ("pi-subagents", git URL, etc.) — sourced from
|
|
867
|
+
* ResolvedResource.metadata.source. The user-facing name. */
|
|
868
|
+
packageSource: { type: "string" },
|
|
869
|
+
tools: {
|
|
870
|
+
type: "array",
|
|
871
|
+
items: {
|
|
872
|
+
type: "object",
|
|
873
|
+
required: ["name", "description", "enabled", "globalEnabled"],
|
|
874
|
+
properties: {
|
|
875
|
+
name: { type: "string" },
|
|
876
|
+
description: { type: "string" },
|
|
877
|
+
enabled: { type: "boolean" },
|
|
878
|
+
globalEnabled: { type: "boolean" },
|
|
879
|
+
projectOverride: { type: "string", enum: ["enabled", "disabled"] },
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
500: errorSchema,
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
}, async (req, reply) => {
|
|
892
|
+
try {
|
|
893
|
+
const overrides = await readToolOverrides();
|
|
894
|
+
const builtinDisabled = new Set(overrides.builtin);
|
|
895
|
+
const mcpDisabled = new Set(overrides.mcp);
|
|
896
|
+
const extensionDisabled = new Set(overrides.extension);
|
|
897
|
+
const projectId = typeof req.query.projectId === "string" && req.query.projectId.length > 0
|
|
898
|
+
? req.query.projectId
|
|
899
|
+
: undefined;
|
|
900
|
+
// Project-scope MCP servers are loaded lazily; trigger a load
|
|
901
|
+
// before reading status so a fresh-after-restart UI fetch
|
|
902
|
+
// doesn't show an empty MCP list for a previously-configured
|
|
903
|
+
// project. Best-effort — load failures shouldn't 500 the
|
|
904
|
+
// whole tool listing.
|
|
905
|
+
let projectWorkspacePath;
|
|
906
|
+
if (projectId !== undefined) {
|
|
907
|
+
const project = await getProject(projectId);
|
|
908
|
+
if (project !== undefined) {
|
|
909
|
+
projectWorkspacePath = project.path;
|
|
910
|
+
await mcpEnsureProjectLoaded(project.id, project.path).catch(() => undefined);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const mcpServers = mcpGetStatus(projectId !== undefined ? { projectId } : undefined);
|
|
914
|
+
// Enumerate pi extensions visible to the project's cwd
|
|
915
|
+
// (or process.cwd as the fallback when no project is
|
|
916
|
+
// selected — same behavior as the agent's discovery on a
|
|
917
|
+
// fresh session). Extension discovery is best-effort: a
|
|
918
|
+
// bad extension manifest must not 500 the whole tools
|
|
919
|
+
// listing.
|
|
920
|
+
const extResources = await discoverExtensionResources(projectWorkspacePath ?? process.cwd());
|
|
921
|
+
// Group tools by package source (e.g. "pi-subagents") for
|
|
922
|
+
// the Settings UI. The package name comes from the resolved
|
|
923
|
+
// ResolvedResource.metadata.source, which is the user-facing
|
|
924
|
+
// npm/git identifier — much friendlier than the extension's
|
|
925
|
+
// entry-file path.
|
|
926
|
+
const extensionGroups = new Map();
|
|
927
|
+
for (const t of extResources.tools) {
|
|
928
|
+
const existing = extensionGroups.get(t.packageSource);
|
|
929
|
+
if (existing === undefined) {
|
|
930
|
+
extensionGroups.set(t.packageSource, [t]);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
existing.push(t);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
builtin: BUILTIN_TOOL_NAMES.map((name) => {
|
|
938
|
+
const globalEnabled = !builtinDisabled.has(name);
|
|
939
|
+
const out = {
|
|
940
|
+
name,
|
|
941
|
+
description: BUILTIN_TOOL_DESCRIPTIONS[name] ?? "",
|
|
942
|
+
enabled: isToolEffective(overrides, projectId, "builtin", name),
|
|
943
|
+
globalEnabled,
|
|
944
|
+
};
|
|
945
|
+
if (projectId !== undefined) {
|
|
946
|
+
const ov = getProjectToolState(overrides, projectId, "builtin", name);
|
|
947
|
+
if (ov !== undefined)
|
|
948
|
+
out.projectOverride = ov;
|
|
949
|
+
}
|
|
950
|
+
return out;
|
|
951
|
+
}),
|
|
952
|
+
mcp: mcpServers.map((s) => {
|
|
953
|
+
const out = {
|
|
954
|
+
server: s.name,
|
|
955
|
+
scope: s.scope,
|
|
956
|
+
enabled: s.enabled,
|
|
957
|
+
state: s.state,
|
|
958
|
+
tools: s.tools.map((t) => {
|
|
959
|
+
const tOut = {
|
|
960
|
+
name: t.name,
|
|
961
|
+
shortName: t.shortName,
|
|
962
|
+
description: t.description,
|
|
963
|
+
enabled: isToolEffective(overrides, projectId, "mcp", t.name),
|
|
964
|
+
globalEnabled: !mcpDisabled.has(t.name),
|
|
965
|
+
};
|
|
966
|
+
if (projectId !== undefined) {
|
|
967
|
+
const ov = getProjectToolState(overrides, projectId, "mcp", t.name);
|
|
968
|
+
if (ov !== undefined)
|
|
969
|
+
tOut.projectOverride = ov;
|
|
970
|
+
}
|
|
971
|
+
return tOut;
|
|
972
|
+
}),
|
|
973
|
+
};
|
|
974
|
+
if (s.projectId !== undefined)
|
|
975
|
+
out.projectId = s.projectId;
|
|
976
|
+
if (s.lastError !== undefined)
|
|
977
|
+
out.lastError = s.lastError;
|
|
978
|
+
return out;
|
|
979
|
+
}),
|
|
980
|
+
extension: Array.from(extensionGroups.entries()).map(([packageSource, tools]) => ({
|
|
981
|
+
packageSource,
|
|
982
|
+
tools: tools.map((t) => {
|
|
983
|
+
const tOut = {
|
|
984
|
+
name: t.name,
|
|
985
|
+
description: t.description ?? "",
|
|
986
|
+
enabled: isToolEffective(overrides, projectId, "extension", t.name),
|
|
987
|
+
globalEnabled: !extensionDisabled.has(t.name),
|
|
988
|
+
};
|
|
989
|
+
if (projectId !== undefined) {
|
|
990
|
+
const ov = getProjectToolState(overrides, projectId, "extension", t.name);
|
|
991
|
+
if (ov !== undefined)
|
|
992
|
+
tOut.projectOverride = ov;
|
|
993
|
+
}
|
|
994
|
+
return tOut;
|
|
995
|
+
}),
|
|
996
|
+
})),
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
return internalError(reply, err);
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
fastify.put("/config/tools/:family/:name/enabled", {
|
|
1004
|
+
schema: {
|
|
1005
|
+
description: "Toggle a single tool by family + name. Family is `builtin` " +
|
|
1006
|
+
"(short bare name like `bash`) or `mcp` (bridged name like " +
|
|
1007
|
+
"`<server>__<tool>` — same name pi sees on the wire). " +
|
|
1008
|
+
'Default `scope: "global"` toggles the tool\'s GLOBAL state — ' +
|
|
1009
|
+
'absence in the disabled set means enabled. `scope: "project"` ' +
|
|
1010
|
+
"(requires `?projectId=`) writes a tri-state per-project " +
|
|
1011
|
+
"override that wins over global: `enabled: true` adds an " +
|
|
1012
|
+
"explicit project-enable, `enabled: false` adds a project- " +
|
|
1013
|
+
"disable. Clear a project override (= inherit global) via " +
|
|
1014
|
+
"`DELETE` on the same path with `?projectId=`. " +
|
|
1015
|
+
"All toggles apply on the NEXT session created; live sessions " +
|
|
1016
|
+
"are unaffected.",
|
|
1017
|
+
tags: ["config"],
|
|
1018
|
+
params: {
|
|
1019
|
+
type: "object",
|
|
1020
|
+
required: ["family", "name"],
|
|
1021
|
+
properties: {
|
|
1022
|
+
family: { type: "string", enum: ["builtin", "mcp", "extension"] },
|
|
1023
|
+
name: { type: "string", minLength: 1 },
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
querystring: {
|
|
1027
|
+
type: "object",
|
|
1028
|
+
properties: { projectId: { type: "string" } },
|
|
1029
|
+
},
|
|
1030
|
+
body: {
|
|
1031
|
+
type: "object",
|
|
1032
|
+
required: ["enabled"],
|
|
1033
|
+
additionalProperties: false,
|
|
1034
|
+
properties: {
|
|
1035
|
+
enabled: { type: "boolean" },
|
|
1036
|
+
scope: { type: "string", enum: ["global", "project"] },
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
response: {
|
|
1040
|
+
200: {
|
|
1041
|
+
type: "object",
|
|
1042
|
+
required: ["family", "name", "enabled", "scope"],
|
|
1043
|
+
properties: {
|
|
1044
|
+
family: { type: "string" },
|
|
1045
|
+
name: { type: "string" },
|
|
1046
|
+
enabled: { type: "boolean" },
|
|
1047
|
+
scope: { type: "string", enum: ["global", "project"] },
|
|
1048
|
+
projectId: { type: "string" },
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
400: errorSchema,
|
|
1052
|
+
404: errorSchema,
|
|
1053
|
+
500: errorSchema,
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
}, async (req, reply) => {
|
|
1057
|
+
try {
|
|
1058
|
+
const scope = req.body.scope ?? "global";
|
|
1059
|
+
if (scope === "project") {
|
|
1060
|
+
const projectId = req.query.projectId;
|
|
1061
|
+
if (typeof projectId !== "string" || projectId.length === 0) {
|
|
1062
|
+
return reply.code(400).send({ error: "missing_project_id" });
|
|
1063
|
+
}
|
|
1064
|
+
// Validate project exists so a typo'd id can't pollute the
|
|
1065
|
+
// overrides file with garbage that never resolves to anything.
|
|
1066
|
+
const project = await getProject(projectId);
|
|
1067
|
+
if (project === undefined) {
|
|
1068
|
+
return reply.code(404).send({ error: "project_not_found" });
|
|
1069
|
+
}
|
|
1070
|
+
const state = req.body.enabled ? "enabled" : "disabled";
|
|
1071
|
+
await setProjectToolOverride(projectId, req.params.family, req.params.name, state);
|
|
1072
|
+
return {
|
|
1073
|
+
family: req.params.family,
|
|
1074
|
+
name: req.params.name,
|
|
1075
|
+
enabled: req.body.enabled,
|
|
1076
|
+
scope,
|
|
1077
|
+
projectId,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
await setToolEnabled(req.params.family, req.params.name, req.body.enabled);
|
|
1081
|
+
return {
|
|
1082
|
+
family: req.params.family,
|
|
1083
|
+
name: req.params.name,
|
|
1084
|
+
enabled: req.body.enabled,
|
|
1085
|
+
scope,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
return internalError(reply, err);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
// Clear a per-project tool override (= return that project to
|
|
1093
|
+
// inheriting the global default). Mirrors the skills DELETE
|
|
1094
|
+
// endpoint's shape.
|
|
1095
|
+
fastify.delete("/config/tools/:family/:name/enabled", {
|
|
1096
|
+
schema: {
|
|
1097
|
+
description: "Clear a per-project tool override so the project inherits " +
|
|
1098
|
+
"the global state. `?projectId=` is required. Idempotent — " +
|
|
1099
|
+
"no-op if no override exists. Returns 404 if the project " +
|
|
1100
|
+
"doesn't exist.",
|
|
1101
|
+
tags: ["config"],
|
|
1102
|
+
params: {
|
|
1103
|
+
type: "object",
|
|
1104
|
+
required: ["family", "name"],
|
|
1105
|
+
properties: {
|
|
1106
|
+
family: { type: "string", enum: ["builtin", "mcp", "extension"] },
|
|
1107
|
+
name: { type: "string", minLength: 1 },
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
querystring: {
|
|
1111
|
+
type: "object",
|
|
1112
|
+
required: ["projectId"],
|
|
1113
|
+
properties: { projectId: { type: "string", minLength: 1 } },
|
|
1114
|
+
},
|
|
1115
|
+
response: {
|
|
1116
|
+
200: {
|
|
1117
|
+
type: "object",
|
|
1118
|
+
required: ["family", "name", "projectId"],
|
|
1119
|
+
properties: {
|
|
1120
|
+
family: { type: "string" },
|
|
1121
|
+
name: { type: "string" },
|
|
1122
|
+
projectId: { type: "string" },
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
404: errorSchema,
|
|
1126
|
+
500: errorSchema,
|
|
1127
|
+
},
|
|
1128
|
+
},
|
|
1129
|
+
}, async (req, reply) => {
|
|
1130
|
+
try {
|
|
1131
|
+
const project = await getProject(req.query.projectId);
|
|
1132
|
+
if (project === undefined) {
|
|
1133
|
+
return reply.code(404).send({ error: "project_not_found" });
|
|
1134
|
+
}
|
|
1135
|
+
await setProjectToolOverride(req.query.projectId, req.params.family, req.params.name, undefined);
|
|
1136
|
+
return {
|
|
1137
|
+
family: req.params.family,
|
|
1138
|
+
name: req.params.name,
|
|
1139
|
+
projectId: req.query.projectId,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
return internalError(reply, err);
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
};
|
|
1147
|
+
/**
|
|
1148
|
+
* One-line user-facing description per built-in tool. Kept here
|
|
1149
|
+
* (not in pi's SDK metadata) because we want operator-friendly
|
|
1150
|
+
* copy that explains the tool's PURPOSE for an audit-style view,
|
|
1151
|
+
* not the LLM-facing prompt snippet the SDK ships. Update if pi
|
|
1152
|
+
* adds new builtins to `ToolName`.
|
|
1153
|
+
*/
|
|
1154
|
+
const BUILTIN_TOOL_DESCRIPTIONS = {
|
|
1155
|
+
read: "Read file contents from the project tree.",
|
|
1156
|
+
bash: "Run shell commands in the project directory.",
|
|
1157
|
+
edit: "Apply a search/replace edit to a file (produces a unified diff).",
|
|
1158
|
+
write: "Create or overwrite a file with new content.",
|
|
1159
|
+
grep: "Search file contents with a regex (ripgrep-backed).",
|
|
1160
|
+
find: "Find files by path glob.",
|
|
1161
|
+
ls: "List directory entries.",
|
|
1162
|
+
};
|
|
1163
|
+
//# sourceMappingURL=config.js.map
|