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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -4
  3. package/bin/pi-forge.mjs +37 -0
  4. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
  5. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
  6. package/dist/client/assets/index-B-529kgJ.css +32 -0
  7. package/dist/client/assets/index-BzKzxXFs.js +392 -0
  8. package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
  9. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
  10. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
  11. package/dist/client/icons/icon-192.png +0 -0
  12. package/dist/client/icons/icon-512.png +0 -0
  13. package/dist/client/icons/icon-maskable-512.png +0 -0
  14. package/dist/client/icons/icon.svg +9 -0
  15. package/dist/client/index.html +24 -0
  16. package/dist/client/manifest.webmanifest +1 -0
  17. package/dist/client/offline.html +142 -0
  18. package/dist/client/sw.js +3 -0
  19. package/dist/client/sw.js.map +1 -0
  20. package/dist/client/workbox-6d7155ed.js +3 -0
  21. package/dist/client/workbox-6d7155ed.js.map +1 -0
  22. package/dist/server/agent-resource-loader.js +126 -0
  23. package/dist/server/agent-resource-loader.js.map +1 -0
  24. package/dist/server/attachment-converters.js +96 -0
  25. package/dist/server/attachment-converters.js.map +1 -0
  26. package/dist/server/auth.js +209 -0
  27. package/dist/server/auth.js.map +1 -0
  28. package/dist/server/compaction-history.js +106 -0
  29. package/dist/server/compaction-history.js.map +1 -0
  30. package/dist/server/concurrency.js +49 -0
  31. package/dist/server/concurrency.js.map +1 -0
  32. package/dist/server/config-export.js +220 -0
  33. package/dist/server/config-export.js.map +1 -0
  34. package/dist/server/config-manager.js +528 -0
  35. package/dist/server/config-manager.js.map +1 -0
  36. package/dist/server/config.js +326 -0
  37. package/dist/server/config.js.map +1 -0
  38. package/dist/server/conversion-worker.mjs +90 -0
  39. package/dist/server/diagnostics.js +137 -0
  40. package/dist/server/diagnostics.js.map +1 -0
  41. package/dist/server/extensions-discovery.js +147 -0
  42. package/dist/server/extensions-discovery.js.map +1 -0
  43. package/dist/server/file-manager.js +734 -0
  44. package/dist/server/file-manager.js.map +1 -0
  45. package/dist/server/file-references.js +215 -0
  46. package/dist/server/file-references.js.map +1 -0
  47. package/dist/server/file-searcher.js +385 -0
  48. package/dist/server/file-searcher.js.map +1 -0
  49. package/dist/server/git-runner.js +684 -0
  50. package/dist/server/git-runner.js.map +1 -0
  51. package/dist/server/index.js +468 -0
  52. package/dist/server/index.js.map +1 -0
  53. package/dist/server/mcp/config.js +133 -0
  54. package/dist/server/mcp/config.js.map +1 -0
  55. package/dist/server/mcp/manager.js +351 -0
  56. package/dist/server/mcp/manager.js.map +1 -0
  57. package/dist/server/mcp/tool-bridge.js +173 -0
  58. package/dist/server/mcp/tool-bridge.js.map +1 -0
  59. package/dist/server/project-manager.js +301 -0
  60. package/dist/server/project-manager.js.map +1 -0
  61. package/dist/server/pty-manager.js +354 -0
  62. package/dist/server/pty-manager.js.map +1 -0
  63. package/dist/server/routes/_schemas.js +73 -0
  64. package/dist/server/routes/_schemas.js.map +1 -0
  65. package/dist/server/routes/auth.js +164 -0
  66. package/dist/server/routes/auth.js.map +1 -0
  67. package/dist/server/routes/config.js +1163 -0
  68. package/dist/server/routes/config.js.map +1 -0
  69. package/dist/server/routes/control.js +464 -0
  70. package/dist/server/routes/control.js.map +1 -0
  71. package/dist/server/routes/exec.js +217 -0
  72. package/dist/server/routes/exec.js.map +1 -0
  73. package/dist/server/routes/files.js +847 -0
  74. package/dist/server/routes/files.js.map +1 -0
  75. package/dist/server/routes/git.js +837 -0
  76. package/dist/server/routes/git.js.map +1 -0
  77. package/dist/server/routes/health.js +97 -0
  78. package/dist/server/routes/health.js.map +1 -0
  79. package/dist/server/routes/mcp.js +300 -0
  80. package/dist/server/routes/mcp.js.map +1 -0
  81. package/dist/server/routes/projects.js +259 -0
  82. package/dist/server/routes/projects.js.map +1 -0
  83. package/dist/server/routes/prompt.js +496 -0
  84. package/dist/server/routes/prompt.js.map +1 -0
  85. package/dist/server/routes/sessions.js +783 -0
  86. package/dist/server/routes/sessions.js.map +1 -0
  87. package/dist/server/routes/stream.js +69 -0
  88. package/dist/server/routes/stream.js.map +1 -0
  89. package/dist/server/routes/terminal.js +335 -0
  90. package/dist/server/routes/terminal.js.map +1 -0
  91. package/dist/server/session-registry.js +1197 -0
  92. package/dist/server/session-registry.js.map +1 -0
  93. package/dist/server/skill-overrides.js +151 -0
  94. package/dist/server/skill-overrides.js.map +1 -0
  95. package/dist/server/skills-export.js +257 -0
  96. package/dist/server/skills-export.js.map +1 -0
  97. package/dist/server/sse-bridge.js +220 -0
  98. package/dist/server/sse-bridge.js.map +1 -0
  99. package/dist/server/tool-overrides.js +277 -0
  100. package/dist/server/tool-overrides.js.map +1 -0
  101. package/dist/server/turn-diff-builder.js +280 -0
  102. package/dist/server/turn-diff-builder.js.map +1 -0
  103. 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