drafted 1.16.0 → 1.17.1
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/agent-instructions/global.md +1 -50
- package/install-mcp.sh +1 -50
- package/mcp/gates.mjs +5 -5
- package/mcp/server.mjs +921 -2663
- package/mcp/test-org-guards.mjs +22 -1
- package/mcp/widgets/canvas-overview.html +19 -3
- package/package.json +1 -1
- package/plugin/commands/create-project.md +7 -10
- package/plugin/commands/create-skill.md +3 -3
- package/plugin/commands/extract.md +2 -2
- package/plugin/commands/improve-project-harness.md +3 -3
- package/plugin/commands/improve-skill.md +2 -2
- package/plugin/commands/improve-wiki.md +3 -3
- package/plugin/commands/ingest.md +3 -3
- package/plugin/commands/onboard-drafted.md +2 -2
- package/plugin/skills/drafted/SKILL.md +35 -31
- package/src/shared/constants.mjs +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -140,10 +140,7 @@ const LOCAL_ONLY_PARAMS = ['file_path'];
|
|
|
140
140
|
// tool wrapper before the handler runs — so the stdio contract, the Zod runtime
|
|
141
141
|
// validation, and every handler stay byte-for-byte unchanged.
|
|
142
142
|
const REMOTE_JSON_STRING_PARAMS = {
|
|
143
|
-
|
|
144
|
-
wiki: ['frontmatter', 'pages'],
|
|
145
|
-
project: ['layers'],
|
|
146
|
-
template: ['layers'],
|
|
143
|
+
fs: ['ops', 'state'],
|
|
147
144
|
minion: ['target', 'checklist', 'output'],
|
|
148
145
|
trigger: ['payload'],
|
|
149
146
|
};
|
|
@@ -217,6 +214,35 @@ export function receiptOrg({ resourceOrgId, sessionOrg, orgList }) {
|
|
|
217
214
|
return found || { id: rid, name: null };
|
|
218
215
|
}
|
|
219
216
|
|
|
217
|
+
// ── Org-scoped path grammar (Shape A: org is the top folder) ────
|
|
218
|
+
// An org is the top of the filesystem: /o/<org>/<root>/... where <root> is
|
|
219
|
+
// wiki | skills | projects. The org is part of the PATH — the agent ADDRESSES an
|
|
220
|
+
// org, it never switches to one — and drives the per-request X-Drafted-Org, so
|
|
221
|
+
// concurrent agents in different orgs cannot affect each other: there is no
|
|
222
|
+
// shared org cursor to clobber. Bare roots (/wiki, /skills, /projects) stay
|
|
223
|
+
// accepted and resolve via the session\'s working org (backward compat).
|
|
224
|
+
// Pure + exported so the grammar is assertable (mcp/test-org-guards.mjs).
|
|
225
|
+
export function splitOrgScope(raw) {
|
|
226
|
+
if (!/^\/o\//.test(raw || '')) return { path: raw, org: null };
|
|
227
|
+
const m = String(raw).match(/^\/o\/([^/]+)(?:\/(.*))?$/);
|
|
228
|
+
if (!m || !m[1]) {
|
|
229
|
+
return { error: `Invalid org-scoped path: ${raw} — expected /o/<org>/<root>/... where <root> is wiki, skills, or projects` };
|
|
230
|
+
}
|
|
231
|
+
return { path: m[2] ? `/${m[2]}` : '/', org: decodeURIComponent(m[1]) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Accept a FULL share URL (https://drafted.live/o/...?...) — humans paste links,
|
|
235
|
+
// not paths. The pathname of a Drafted URL IS the fs path (Q2: one canonical link
|
|
236
|
+
// for both consumers), so stripping the origin leaves an addressable path. Falls
|
|
237
|
+
// back to the input unchanged when it isn't a URL. Pure + exported (asserted in
|
|
238
|
+
// mcp/test-org-guards.mjs).
|
|
239
|
+
export function stripUrlOrigin(p) {
|
|
240
|
+
const s = String(p || '');
|
|
241
|
+
if (!/^https?:\/\//i.test(s)) return s;
|
|
242
|
+
try { return new URL(s).pathname; } catch { return s; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
220
246
|
export function createMcpServer(transport) {
|
|
221
247
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
222
248
|
// server, not the user's machine, so local-filesystem params like `file_path`
|
|
@@ -234,24 +260,24 @@ const server = new McpServer({
|
|
|
234
260
|
version: PACKAGE_VERSION,
|
|
235
261
|
description: `Multi-tenant design workspace. Structure: Organization → Projects → Layers → Lanes → Frames.
|
|
236
262
|
|
|
237
|
-
|
|
263
|
+
Drafted is navigated like a local filesystem — one tool (fs), one path grammar, org first: fs(ls, path="/") lists the orgs you can address, then /o/<org>/wiki/<path> (org knowledge pages), /o/<org>/skills/<slug> (reusable procedures), /o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file> (frames on a zoomable canvas). The project is resolved from the path itself — no separate "open" step, and the org is part of the path — no org switching.
|
|
238
264
|
|
|
239
|
-
WORKFLOW:
|
|
265
|
+
WORKFLOW: fs(ls, path="/") → fs(ls, path="/o/<org>/projects") → fs(read/write/edit, path="/o/<org>/projects/<project>/<layer>/<lane>/<file>"). Address resources by UUID (org self-derives) and write wiki/skills under /o/<org>/... so the org is explicit. (Bare /wiki, /skills, /projects roots still resolve via the session\'s working org.)
|
|
240
266
|
|
|
241
|
-
SKILLS:
|
|
267
|
+
SKILLS: /skills/<slug> holds reusable agent instructions (SKILL.md files). When a user says "use the X skill", fs(ls, path="/skills") then fs(read, path="/skills/<slug>"). Skills can cover anything: UX guidelines, copywriting rules, brand voice, coding standards, review checklists, etc.
|
|
242
268
|
|
|
243
|
-
BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via read
|
|
269
|
+
BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via fs(read, path="/f/<frameId>"). One line per related frame. Skip for throwaway or exploratory frames.
|
|
244
270
|
|
|
245
271
|
CONTEXT RULES (follow these before every action):
|
|
246
|
-
- WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use
|
|
272
|
+
- WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use fs(search, query="<terms>") with the /wiki root.
|
|
247
273
|
- LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
|
|
248
|
-
IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read
|
|
274
|
+
IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use fs(read, path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.
|
|
249
275
|
|
|
250
276
|
LINKING: link the user to the narrowest thing you touched, never the project as a stand-in. Wrote or edited ONE frame → give its \`frameUrl\` (from the write/read/ls response). Touched a whole lane → its \`laneUrl\`; a whole layer → its \`layerUrl\` (both on the matching \`ls\` entries). Only when the work spans the project is the project URL the right link. A project link where a frame link was available makes the user hunt the surface for what you just did.`,
|
|
251
277
|
}, {
|
|
252
278
|
// Initialize instructions: the agent-identity contract, so an agent learns its own
|
|
253
279
|
// tab name + the right way to read it WITHOUT having to "think to" call a tool.
|
|
254
|
-
instructions: `SESSION IDENTITY — read this first: you run as a NAMED session tab visible to the user on their Drafted surface. Your session
|
|
280
|
+
instructions: `SESSION IDENTITY — read this first: you run as a NAMED session tab visible to the user on their Drafted surface. Your session name is how the user matches YOUR window to the tab they see. NAME YOURSELF on first connect: call whoami first — if it reports nameRequired, all other tools are gated until you set a short 2-3 word name describing the work with session(action="name", name="...") (whoami suggests one from your working directory). The name persists across reconnects, so you only set it once. Read your name from the whoami tool; call whoami to refresh after a reconnect.
|
|
255
281
|
|
|
256
282
|
DUAL REGISTRATION IS NORMAL: a separate "Drafted" remote connector (https://drafted.live/mcp, managed by claude.ai) may appear alongside this local stdio server in MCP listings. It is NOT a duplicate and NOT broken — it serves claude.ai web/mobile/Cowork, where a local stdio process cannot run. Never advise removing it; a "needs authentication" state on it is fixed by signing in from claude.ai and does not affect this stdio session.${isRemote ? `
|
|
257
283
|
|
|
@@ -275,76 +301,31 @@ const TOOL_ANNOTATIONS = {
|
|
|
275
301
|
auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL immediately and starts background approval polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval.' },
|
|
276
302
|
|
|
277
303
|
// Identity — read-only introspection of THIS agent's session
|
|
278
|
-
whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
|
|
279
|
-
|
|
280
|
-
// Health — call once per session, right after whoami and before any real work
|
|
281
|
-
health: { title: 'Server health', readOnlyHint: true, destructiveHint: false, openWorldHint: true, description: 'Server reachability + installed MCP version/update status. Call this once per session, right after whoami and before doing any real work, so a required update surfaces before you act on stale tool behavior.' },
|
|
282
|
-
|
|
283
|
-
// Projects
|
|
284
|
-
project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (bind this agent session to a project — org derives from it), create (org= names where it is born), update, move to another org.' },
|
|
285
|
-
get_org: { title: 'Organization', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'List your orgs, the default org, and Google Drive availability (action="get", default), or fetch installed MCP update instructions (action="update_mcp"). There is no org switching — org derives from the resource you address; creates/searches take org=. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
|
|
304
|
+
whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state — PLUS server health and the installed MCP version/update status (cached ~5min). Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
|
|
286
305
|
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// Layers
|
|
291
|
-
layer: { title: 'Layers', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage layers in a project: add, update, remove, reorder.' },
|
|
292
|
-
|
|
293
|
-
// Frames — filesystem
|
|
294
|
-
ls: { title: 'List frames', readOnlyHint: true, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html' },
|
|
295
|
-
frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: true, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, search, restore frame versions, create Google Workspace frames, or write values into Google Sheet frames in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
|
|
296
|
-
rm: { title: 'Delete frame', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
297
|
-
// batch: { title: 'Batch operations', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
306
|
+
// Session naming — the name-before-work gate: every agent session must set a short
|
|
307
|
+
// name describing the work before any other tool call succeeds.
|
|
308
|
+
session: { title: 'Session', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes. Dispatch by `action`: `name` (set/rename with the `name` param).' },
|
|
298
309
|
|
|
299
310
|
// Canvas / view
|
|
300
311
|
focus: { title: 'Focus on target', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Pan the canvas viewport for connected clients to a frame, lane, or layer.' },
|
|
301
312
|
screenshot: { title: 'Screenshot', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Render a PNG via headless browser. `scope=frame` for a single frame, `scope=canvas` for a region of the project surface.' },
|
|
302
313
|
|
|
303
|
-
// Assets
|
|
304
|
-
asset: { title: 'Assets', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Manage project assets (CSS/JS/images/fonts referenced by frames). `action=upload` or `action=list`.' },
|
|
305
|
-
|
|
306
|
-
// Connectors / layout
|
|
307
|
-
connector: { title: 'Connectors', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Connect or disconnect frames with arrows on the surface. `action=connect` or `action=disconnect`.' },
|
|
308
|
-
layout: { title: 'Auto-layout', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
309
|
-
|
|
310
|
-
// Skills
|
|
311
|
-
skill: { title: 'Skills', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage the Drafted skill library: search, load, add, update, remove, attach/detach from projects, favorite, and edit skill files.' },
|
|
312
|
-
wiki: { title: 'Wiki', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Per-org wiki. Markdown pages with paths as hierarchy. Dispatch by `action`.' },
|
|
313
|
-
|
|
314
314
|
// Minions — checklist-driven intake surfaces bound to a project
|
|
315
|
-
minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and
|
|
316
|
-
trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (returns URL + secret token ONCE — relay it to the user immediately,
|
|
315
|
+
minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and write a producible into the project. Dispatch by `action`: meta, list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say/test_resolve — drive the checklist conversation yourself (works even when disabled). Requires the agent allowlist.' },
|
|
316
|
+
trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (returns URL + secret token ONCE — relay it to the user immediately, not retrievable later), list, update (enable/disable, edit template, daily limit, executor), rotate (new token), test (fire a synthetic delivery), deliveries (audit log), delete; for executor="queue" triggers, pending/claim/complete let a LOCAL agent poll and work queued deliveries. Requires the agent allowlist.' },
|
|
317
|
+
fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search.' },
|
|
317
318
|
};
|
|
318
319
|
|
|
319
320
|
function isMutatingToolCall(name, args = {}) {
|
|
320
321
|
const action = args?.action;
|
|
321
322
|
switch (name) {
|
|
322
|
-
case 'project':
|
|
323
|
-
return ['create', 'update', 'move'].includes(action);
|
|
324
|
-
case 'template':
|
|
325
|
-
return ['create', 'update', 'delete', 'fork'].includes(action);
|
|
326
|
-
case 'layer':
|
|
327
|
-
return ['add', 'update', 'remove', 'reorder'].includes(action);
|
|
328
|
-
case 'frame':
|
|
329
|
-
return ![
|
|
330
|
-
'read', 'search', 'versions', 'read_version',
|
|
331
|
-
'get_sheet', 'read_sheet_values',
|
|
332
|
-
'get_doc', 'read_doc_content',
|
|
333
|
-
'get_slide', 'read_slide_content',
|
|
334
|
-
'get_excel', 'read_excel_range',
|
|
335
|
-
].includes(action);
|
|
336
|
-
case 'asset':
|
|
337
|
-
return ['upload', 'rm'].includes(action);
|
|
338
|
-
case 'skill':
|
|
339
|
-
return ['add', 'update', 'remove', 'attach', 'detach', 'favorite', 'unfavorite', 'update_file'].includes(action);
|
|
340
|
-
case 'wiki':
|
|
341
|
-
return ['log', 'write', 'edit', 'mv', 'rm', 'source-register', 'bulk-write'].includes(action);
|
|
342
323
|
case 'minion':
|
|
343
324
|
return ['create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve'].includes(action);
|
|
344
|
-
case '
|
|
345
|
-
|
|
346
|
-
case '
|
|
347
|
-
return
|
|
325
|
+
case 'trigger':
|
|
326
|
+
return ['create', 'update', 'rotate', 'delete'].includes(action);
|
|
327
|
+
case 'fs':
|
|
328
|
+
return !['ls', 'read', 'search'].includes(action);
|
|
348
329
|
default:
|
|
349
330
|
return false;
|
|
350
331
|
}
|
|
@@ -1109,7 +1090,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false, _org
|
|
|
1109
1090
|
if (sess._stdio) savePersistedProject(null, { serverUrl: getServerUrl() });
|
|
1110
1091
|
throw new Error(
|
|
1111
1092
|
`${msg} — the active project (${meta?.slug || pid}) is no longer in the current org. ` +
|
|
1112
|
-
`Active project cleared.
|
|
1093
|
+
`Active project cleared. Address an /o/<org>/projects/<project>/... path or fs(ls, path="/o/<org>/projects") to bind one; org-scoped wiki/skills (fs /o/<org>/wiki, fs /o/<org>/skills) work without it.`
|
|
1113
1094
|
);
|
|
1114
1095
|
}
|
|
1115
1096
|
// Attach the structured body so callers can key off machine-readable fields
|
|
@@ -1117,6 +1098,13 @@ async function api(method, path, body, extraHeaders = {}, _retried = false, _org
|
|
|
1117
1098
|
const apiErr = new Error(msg);
|
|
1118
1099
|
apiErr.status = res.status;
|
|
1119
1100
|
if (data && typeof data === 'object') { apiErr.code = data.code; apiErr.body = data; }
|
|
1101
|
+
// Name-before-work gate: enrich the server message with the LOCAL working-directory
|
|
1102
|
+
// suggestion (the server doesn't know cwd) so the agent can accept it in one call
|
|
1103
|
+
// even if it never ran whoami.
|
|
1104
|
+
if (apiErr.code === 'session_unnamed') {
|
|
1105
|
+
const suggest = getSuggestedSessionName();
|
|
1106
|
+
if (suggest) apiErr.message += ` Suggested from your working directory: "${suggest}" — accept it with session(action="name", name="${suggest}") or pick your own 2-3 word name.`;
|
|
1107
|
+
}
|
|
1120
1108
|
throw apiErr;
|
|
1121
1109
|
}
|
|
1122
1110
|
return data;
|
|
@@ -1184,8 +1172,8 @@ function shapeSkillCatalog(result, { limit, offset = 0, compact = false } = {})
|
|
|
1184
1172
|
result.truncated = start + page.length < total;
|
|
1185
1173
|
result.skills = page.map(compact ? compactSkillEntry : summarizeSkillForSearch);
|
|
1186
1174
|
result.note = compact
|
|
1187
|
-
? 'Compact catalog: {slug,name,tags} only. Load full content with
|
|
1188
|
-
: 'Search/list returns skill summaries only (no SKILL.md body). Use
|
|
1175
|
+
? 'Compact catalog: {slug,name,tags} only. Load full content with fs(read, path="/skills/<slug>"). Pass compact=false for descriptions/triggerPatterns; use offset to page.'
|
|
1176
|
+
: 'Search/list returns skill summaries only (no SKILL.md body). Use fs(read, path="/skills/<slug-or-id>") for full content. Pass compact=true for a leaner {slug,name,tags} catalog; use limit/offset to page.';
|
|
1189
1177
|
return result;
|
|
1190
1178
|
}
|
|
1191
1179
|
|
|
@@ -1319,6 +1307,20 @@ function setMcpActiveProject(projectId, meta = null) {
|
|
|
1319
1307
|
// ponytail: process-lifetime cache, no TTL — a project rename goes stale until restart.
|
|
1320
1308
|
// Add invalidation if renames ever become common.
|
|
1321
1309
|
const projectRefCache = new Map();
|
|
1310
|
+
|
|
1311
|
+
// Does an org-scoped path's org segment (/o/<org>/...) address the org of a
|
|
1312
|
+
// resolved project? Accepts the org id, slug, or name. Rejects paths whose org
|
|
1313
|
+
// segment contradicts the project's row org — the silent-strip gap in the old
|
|
1314
|
+
// URL-form alias, where /o/<orgX>/projects/<projInOrgY>/... wrote to orgY as if
|
|
1315
|
+
// orgX had been addressed.
|
|
1316
|
+
async function pathOrgMatches(orgSegment, meta) {
|
|
1317
|
+
if (meta?.orgId && orgSegment === meta.orgId) return true;
|
|
1318
|
+
if (meta?.orgSlug && orgSegment === meta.orgSlug) return true;
|
|
1319
|
+
const orgs = await getOrgList();
|
|
1320
|
+
const hit = orgs.find(o => o.id === orgSegment || (o.slug && o.slug === orgSegment) || (o.name || '').toLowerCase() === String(orgSegment).toLowerCase());
|
|
1321
|
+
return !!hit && meta?.orgId === hit.id;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1322
1324
|
async function resolveProjectRef(ref) {
|
|
1323
1325
|
if (!ref) return null;
|
|
1324
1326
|
const hit = projectRefCache.get(ref);
|
|
@@ -1337,6 +1339,87 @@ async function resolveProjectRef(ref) {
|
|
|
1337
1339
|
return meta;
|
|
1338
1340
|
}
|
|
1339
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* Resolve a project argument to its meta, accepting the pseudo-filesystem path
|
|
1344
|
+
* form (/projects/<name> or /projects/<folder>/<name>) OR a bare slug / name /
|
|
1345
|
+
* UUID — all through the same resolver, so every tool that names a project
|
|
1346
|
+
* behaves identically. Throws with a clear message when missing or unknown.
|
|
1347
|
+
*/
|
|
1348
|
+
async function resolveProjectArg(projectArg, { required = true } = {}) {
|
|
1349
|
+
if (!projectArg) {
|
|
1350
|
+
if (!required) return null;
|
|
1351
|
+
throw new Error('project required — pass it as /projects/<name> (e.g. projectId="/projects/beoflow"), a slug, or a UUID');
|
|
1352
|
+
}
|
|
1353
|
+
let ref = String(projectArg);
|
|
1354
|
+
// Org-scoped path (/o/<org>/projects/<name>) and bare root form
|
|
1355
|
+
// (/projects/<name> or /projects/<folder>/<name>) → take the last segment as the ref.
|
|
1356
|
+
const scoped = splitOrgScope(ref);
|
|
1357
|
+
if (scoped.error) throw new Error(scoped.error);
|
|
1358
|
+
ref = scoped.path || '';
|
|
1359
|
+
if (ref.startsWith('/projects')) {
|
|
1360
|
+
const parts = ref.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
|
|
1361
|
+
if (parts.length === 0) throw new Error(`Invalid project path: ${projectArg}`);
|
|
1362
|
+
ref = parts[parts.length - 1];
|
|
1363
|
+
}
|
|
1364
|
+
const meta = await resolveProjectRef(ref);
|
|
1365
|
+
if (!meta) throw new Error(`Project not found: ${projectArg} — list projects with fs(ls, path="/projects")`);
|
|
1366
|
+
return meta;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Resolve a pseudo-filesystem path to a frame ID.
|
|
1371
|
+
* Accepts:
|
|
1372
|
+
* /projects/<project>/<layer>/<lane>/<file> (canonical)
|
|
1373
|
+
* /projects/<folder>/<project>/<layer>/<lane>/<file>
|
|
1374
|
+
* /projects/<project>/<layer>/<file> (layer-root file)
|
|
1375
|
+
* /o/<org>/projects/<project>/<layer>/<lane>/<file> (URL form)
|
|
1376
|
+
* /f/<uuid> | bare UUID
|
|
1377
|
+
* Returns { frameId } or throws with a helpful message.
|
|
1378
|
+
*/
|
|
1379
|
+
async function resolveFsFramePath(path) {
|
|
1380
|
+
const urlMatch = String(path).match(/\/f\/([a-f0-9-]{36})/);
|
|
1381
|
+
const uuidMatch = String(path).match(/^[a-f0-9-]{36}$/);
|
|
1382
|
+
if (urlMatch?.[1] || uuidMatch) return { frameId: urlMatch?.[1] || uuidMatch[0] };
|
|
1383
|
+
|
|
1384
|
+
const p = stripUrlOrigin(String(path));
|
|
1385
|
+
// URL form: /o/<org>/projects/<project>/<layer>/<lane>/<file>
|
|
1386
|
+
let fsPath = p;
|
|
1387
|
+
const urlForm = p.match(/^\/o\/[^/]+\/projects\/(.+)$/);
|
|
1388
|
+
if (urlForm) fsPath = '/projects/' + urlForm[1];
|
|
1389
|
+
if (!fsPath.startsWith('/projects')) {
|
|
1390
|
+
throw new Error('Target must be a frame URL, frame ID, or path /projects/<project>/<layer>/<lane>/<file>');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const parts = fsPath.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
|
|
1394
|
+
if (parts.length < 3) throw new Error('Path must include the project, layer, and file: /projects/<project>/<layer>/<lane>/<file>');
|
|
1395
|
+
// <project>/<layer>/<lane>/<file> (no folder) or <folder>/<project>/<layer>/<lane>/<file>
|
|
1396
|
+
const projectRef = parts.length === 4 ? parts[0] : parts.length >= 5 ? parts[1] : parts[0];
|
|
1397
|
+
let layer, lane = null, filename;
|
|
1398
|
+
if (parts.length >= 4) {
|
|
1399
|
+
// canonical: .../<layer>/<lane>/<file>
|
|
1400
|
+
layer = parts[parts.length - 3];
|
|
1401
|
+
lane = parts[parts.length - 2];
|
|
1402
|
+
filename = parts[parts.length - 1];
|
|
1403
|
+
} else {
|
|
1404
|
+
// 3 segments after project: <layer>/<file> (layer-root) or <layer>/<lane> (lane view).
|
|
1405
|
+
// A filename has an extension; a lane name usually doesn't.
|
|
1406
|
+
layer = parts[1];
|
|
1407
|
+
if (/\.[a-z0-9]+$/i.test(parts[2])) {
|
|
1408
|
+
filename = parts[2]; // layer-root file
|
|
1409
|
+
} else {
|
|
1410
|
+
throw new Error('Path must include a file: /projects/<project>/<layer>/<lane>/<file>');
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const meta = await resolveProjectRef(projectRef);
|
|
1414
|
+
if (!meta) throw new Error(`Project not found: ${projectRef}`);
|
|
1415
|
+
const filePath = lane
|
|
1416
|
+
? `${encodeURIComponent(layer)}/${encodeURIComponent(lane)}/${encodeURIComponent(filename)}`
|
|
1417
|
+
: `${encodeURIComponent(layer)}/${encodeURIComponent(filename)}`;
|
|
1418
|
+
const frame = await withProjectOverride(meta, () => api('GET', `/api/fs/${filePath}`));
|
|
1419
|
+
if (!frame?.id) throw new Error(`Frame not found: ${path}`);
|
|
1420
|
+
return { frameId: frame.id };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1340
1423
|
// Run one tool call against a caller-named project WITHOUT rebinding the session.
|
|
1341
1424
|
// The override lives in this call's AsyncLocalStorage frame only — request-local, so
|
|
1342
1425
|
// it satisfies the DRAFT-36 addressing invariant (no shared, another-session-writable
|
|
@@ -1353,12 +1436,12 @@ function withProjectOverride(meta, fn) {
|
|
|
1353
1436
|
|
|
1354
1437
|
// Tools that address frames/assets/layout inside ONE project. These accept the
|
|
1355
1438
|
// per-call `projectId` override; everything else resolves org-side.
|
|
1356
|
-
const PROJECT_SCOPED_TOOLS = new Set(['
|
|
1439
|
+
const PROJECT_SCOPED_TOOLS = new Set(['fs']);
|
|
1357
1440
|
|
|
1358
1441
|
const PROJECT_OVERRIDE_PARAM = z.string().optional().describe(
|
|
1359
1442
|
'Target project (UUID, slug, or name) for THIS call only — does NOT rebind the session. ' +
|
|
1360
1443
|
'Use it when the session lost its binding (a reconnect), or to touch another project once ' +
|
|
1361
|
-
'without re-opening it. Omit to
|
|
1444
|
+
'without re-opening it. Omit to derive the project from the path (/projects/<project>/...).',
|
|
1362
1445
|
);
|
|
1363
1446
|
|
|
1364
1447
|
// Returns { id, slug, name, orgId } for the project this MCP session most
|
|
@@ -1396,7 +1479,9 @@ async function connectAgentWs() {
|
|
|
1396
1479
|
console.error('[MCP-WS] Connected');
|
|
1397
1480
|
// Announce presence immediately so the agent surfaces (greyed/idle, no project) the
|
|
1398
1481
|
// moment it connects — before opening any project. Project work later flips it active.
|
|
1399
|
-
|
|
1482
|
+
// suggestedName (basename of the working directory) lets the server offer it in the
|
|
1483
|
+
// name-before-work gate message and as the tab placeholder.
|
|
1484
|
+
try { agentWs.send(JSON.stringify({ type: 'agent-hello', agentLabel: getAgentLabel(), suggestedName: getSuggestedSessionName() })); } catch {}
|
|
1400
1485
|
if (getState().projectId) {
|
|
1401
1486
|
agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() }));
|
|
1402
1487
|
}
|
|
@@ -1411,6 +1496,7 @@ async function connectAgentWs() {
|
|
|
1411
1496
|
agentSurface = {
|
|
1412
1497
|
sessionId: m.sessionId, userId: m.userId, orgId: m.orgId, projectId: m.projectId,
|
|
1413
1498
|
name: m.name, capital: m.capital, color: m.color, alive: m.alive, surfaced: true,
|
|
1499
|
+
nameRequired: !!m.nameRequired, suggestedName: m.suggestedName || null,
|
|
1414
1500
|
capturedAt: Date.now(),
|
|
1415
1501
|
};
|
|
1416
1502
|
}
|
|
@@ -1446,6 +1532,21 @@ function getAgentLabel() {
|
|
|
1446
1532
|
return proj ? `${editor} - ${proj}` : editor;
|
|
1447
1533
|
}
|
|
1448
1534
|
|
|
1535
|
+
// Suggested session name for the name-before-work gate: the user-set DRAFTED_AGENT_NAME
|
|
1536
|
+
// when present (it describes the work), else the basename of this process's working
|
|
1537
|
+
// directory — the durable session context Claude Code / Codex / pi all record per session
|
|
1538
|
+
// (their transcripts carry cwd; the MCP stdio process is spawned there). Terminal tabs
|
|
1539
|
+
// use the same convention, so the user recognizes it.
|
|
1540
|
+
function getSuggestedSessionName() {
|
|
1541
|
+
const explicit = (process.env.DRAFTED_AGENT_NAME || '').trim();
|
|
1542
|
+
if (explicit) return explicit;
|
|
1543
|
+
try {
|
|
1544
|
+
const base = basename(process.cwd());
|
|
1545
|
+
if (base && base !== '/') return base;
|
|
1546
|
+
} catch { /* fall through */ }
|
|
1547
|
+
return null;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1449
1550
|
async function joinAgentWsRoom(projectId) {
|
|
1450
1551
|
// Ensure WS is connected (may not be after restart/session re-clone)
|
|
1451
1552
|
if (!agentWs || agentWs.readyState > WebSocket.OPEN) {
|
|
@@ -1461,12 +1562,12 @@ async function joinAgentWsRoom(projectId) {
|
|
|
1461
1562
|
}
|
|
1462
1563
|
|
|
1463
1564
|
// Tools that are pure introspection/sign-in, not "the agent started working" — excluded from
|
|
1464
|
-
// the first-substantive-action ping below so a bare whoami/
|
|
1565
|
+
// the first-substantive-action ping below so a bare whoami/auth never yanks the
|
|
1465
1566
|
// desktop app's window to the front.
|
|
1466
|
-
const NON_SUBSTANTIVE_TOOLS = new Set(['whoami', '
|
|
1567
|
+
const NON_SUBSTANTIVE_TOOLS = new Set(['whoami', 'auth']);
|
|
1467
1568
|
let hasAnnouncedSubstantiveWork = false;
|
|
1468
1569
|
|
|
1469
|
-
// Tell the server this agent has started real work (first call past whoami/
|
|
1570
|
+
// Tell the server this agent has started real work (first call past whoami/auth this
|
|
1470
1571
|
// process), so it can foreground the desktop app's window. Fire-and-forget — never blocks or
|
|
1471
1572
|
// fails the tool call that triggered it.
|
|
1472
1573
|
async function announceSubstantiveWork() {
|
|
@@ -1552,22 +1653,23 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
|
|
|
1552
1653
|
return;
|
|
1553
1654
|
}
|
|
1554
1655
|
if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
|
|
1555
|
-
// Actionable, not a dead end: name the orgs and the
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1656
|
+
// Actionable, not a dead end: name the orgs and the address that resolves it.
|
|
1657
|
+
// Project-less wiki and skill work is a first-class flow — no project required, ever.
|
|
1658
|
+
// The only thing refused is GUESSING which org, which is what silently misfiled a
|
|
1659
|
+
// page into the user's default org. (Listing the orgs is safe: they're the caller's
|
|
1660
|
+
// own memberships.) Shape A recovery: the org is the top folder — address it in the
|
|
1661
|
+
// path, no binding call exists anymore (fs(ls, path="/", org=...) does NOT bind).
|
|
1560
1662
|
const names = orgs.map(o => o.name || o.id).filter(Boolean);
|
|
1561
1663
|
throw new Error(
|
|
1562
1664
|
`Which org? A project-less wiki/skill write needs one, and you belong to ${orgs.length}: ` +
|
|
1563
1665
|
`${names.join(', ')}. Don't guess — an unaddressed write lands in whichever org this ` +
|
|
1564
1666
|
`session inherited, which is how a page meant for one org ends up in another.\n` +
|
|
1565
|
-
`
|
|
1566
|
-
`
|
|
1567
|
-
|
|
1568
|
-
`
|
|
1569
|
-
`(Alternatives: org="<name>" on this single call; or project
|
|
1570
|
-
`
|
|
1667
|
+
`Address the org in the PATH — it's the top folder of the filesystem: ` +
|
|
1668
|
+
`fs(ls, path="/") lists your orgs, then write to /o/<org>/wiki/... or ` +
|
|
1669
|
+
`/o/<org>/skills/... — the org is part of the path, and every later write to that ` +
|
|
1670
|
+
`path just works with no org= and no project.\n` +
|
|
1671
|
+
`(Alternatives: org="<name>" on this single call; or a /o/<org>/projects/<project>/... ` +
|
|
1672
|
+
`path — the org derives from the project.)\n` +
|
|
1571
1673
|
`Pick from the conversation if the org is clear from context; ask the user only if it isn't.`
|
|
1572
1674
|
);
|
|
1573
1675
|
}
|
|
@@ -1582,9 +1684,15 @@ async function getOrgList() {
|
|
|
1582
1684
|
if (sess.cachedOrgs && Date.now() - (sess.cachedOrgsTime || 0) < 30000) return sess.cachedOrgs;
|
|
1583
1685
|
try {
|
|
1584
1686
|
const d = await api('GET', '/api/orgs');
|
|
1585
|
-
sess.cachedOrgs = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
1687
|
+
sess.cachedOrgs = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name, slug: o.slug || null, isActive: !!o.isActive }));
|
|
1586
1688
|
sess.cachedOrgsTime = Date.now();
|
|
1587
|
-
} catch {
|
|
1689
|
+
} catch (e) {
|
|
1690
|
+
// The name-before-work gate must surface, not be swallowed by this defensive catch —
|
|
1691
|
+
// otherwise an unnamed agent sees "no orgs to list" instead of the gate that tells
|
|
1692
|
+
// it to name itself. Everything else degrades to [] ("can't determine membership").
|
|
1693
|
+
if (e?.code === 'session_unnamed') throw e;
|
|
1694
|
+
sess.cachedOrgs = sess.cachedOrgs || [];
|
|
1695
|
+
}
|
|
1588
1696
|
return sess.cachedOrgs;
|
|
1589
1697
|
}
|
|
1590
1698
|
|
|
@@ -1666,7 +1774,7 @@ async function checkOrgSkills(orgId, operation) {
|
|
|
1666
1774
|
const loaded = getSessionState().loadedSkillIds;
|
|
1667
1775
|
const unloaded = skills.filter(s => !loaded.has(s.id));
|
|
1668
1776
|
if (unloaded.length === 0) return null;
|
|
1669
|
-
const lines = unloaded.map(s => `
|
|
1777
|
+
const lines = unloaded.map(s => ` fs(read, path="/skills/${s.slug || s.id}") -- ${s.name}`);
|
|
1670
1778
|
return `This org has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
|
|
1671
1779
|
`Unloaded skills:\n${lines.join('\n')}\n\n` +
|
|
1672
1780
|
`Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
|
|
@@ -1800,7 +1908,11 @@ async function launchDesktopSignin() {
|
|
|
1800
1908
|
const child = spawn(bin, ['--open-login'], {
|
|
1801
1909
|
detached: true,
|
|
1802
1910
|
stdio: 'ignore',
|
|
1803
|
-
|
|
1911
|
+
// DRAFTED_OPEN_LOGIN=1 makes a fresh primary instance open sign-in on boot. The app's
|
|
1912
|
+
// base_url() reads DRAFTED_DESKTOP_URL (default https://drafted.live) — pass the server
|
|
1913
|
+
// this MCP is bound to, or a local install's sign-in window would target production and
|
|
1914
|
+
// the cookie→auth.json bridge would write the production file, never DRAFTED_AUTH_FILE.
|
|
1915
|
+
env: { ...process.env, DRAFTED_OPEN_LOGIN: '1', DRAFTED_DESKTOP_URL: getServerUrl() },
|
|
1804
1916
|
});
|
|
1805
1917
|
child.unref();
|
|
1806
1918
|
return true;
|
|
@@ -1966,6 +2078,8 @@ async function sessionSurfaceBlock() {
|
|
|
1966
2078
|
color: agentSurface.color,
|
|
1967
2079
|
surfaced: true,
|
|
1968
2080
|
alive: !!agentSurface.alive,
|
|
2081
|
+
nameRequired: !!agentSurface.nameRequired,
|
|
2082
|
+
suggestedName: agentSurface.suggestedName || getSuggestedSessionName(),
|
|
1969
2083
|
// `alive` was ambiguous: agents couldn't tell whether it meant "your writes are
|
|
1970
2084
|
// no-ops" (act differently) or "the user's canvas tab is closed" (ignore). It's the
|
|
1971
2085
|
// latter — say so, so nobody changes behavior over it.
|
|
@@ -1974,7 +2088,8 @@ async function sessionSurfaceBlock() {
|
|
|
1974
2088
|
: 'No canvas surface is currently open for this session. Frame, wiki, and skill writes all persist normally — only focus/presence have nothing to draw on. Do not change what you write because of this.',
|
|
1975
2089
|
};
|
|
1976
2090
|
}
|
|
1977
|
-
// No WS ack yet — best-effort identity from /auth/me so callers still get a userId/org
|
|
2091
|
+
// No WS ack yet — best-effort identity from /auth/me so callers still get a userId/org
|
|
2092
|
+
// AND the name-before-work gate state (the WS ack races the first tool call).
|
|
1978
2093
|
const cookieSid = sessionId || getBootstrapSessionId();
|
|
1979
2094
|
let me = null;
|
|
1980
2095
|
let unreachable = false;
|
|
@@ -1992,13 +2107,16 @@ async function sessionSurfaceBlock() {
|
|
|
1992
2107
|
if (String(e?.message || '').startsWith('TLS interception')) unreachableWhy = e.message;
|
|
1993
2108
|
}
|
|
1994
2109
|
}
|
|
2110
|
+
const nameRequired = !!me?.agentClone && !me?.surfaceName;
|
|
1995
2111
|
return {
|
|
1996
2112
|
sessionId: cookieSid,
|
|
1997
2113
|
userId: me?.userId ?? null,
|
|
1998
2114
|
orgId: me?.currentOrg?.id ?? null,
|
|
1999
2115
|
projectId: getState().projectId ?? null,
|
|
2000
|
-
name: null,
|
|
2001
|
-
capital: null,
|
|
2116
|
+
name: me?.surfaceName ?? null,
|
|
2117
|
+
capital: me?.surfaceName ? (me.surfaceName[0] || '').toUpperCase() : null,
|
|
2118
|
+
nameRequired,
|
|
2119
|
+
suggestedName: getSuggestedSessionName(),
|
|
2002
2120
|
color: null,
|
|
2003
2121
|
surfaced: false,
|
|
2004
2122
|
alive: false,
|
|
@@ -2009,18 +2127,30 @@ async function sessionSurfaceBlock() {
|
|
|
2009
2127
|
};
|
|
2010
2128
|
}
|
|
2011
2129
|
|
|
2012
|
-
// Identity: report THIS agent session's own surface identity
|
|
2013
|
-
|
|
2130
|
+
// Identity + server health: report THIS agent session's own surface identity, server
|
|
2131
|
+
// reachability, and installed-MCP staleness in ONE bootstrap call. The update data is
|
|
2132
|
+
// cached (5min), so repeat `whoami` calls are free; the server-side update gate still
|
|
2133
|
+
// blocks mutating calls on its own, independent of this tool. Read-only — no state changed.
|
|
2134
|
+
tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state — PLUS server health and the installed MCP version/update status (cached ~5min). Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
|
|
2014
2135
|
try {
|
|
2136
|
+
// Ensure the child clone exists BEFORE reading identity — otherwise the /auth/me
|
|
2137
|
+
// fallback (pre-WS-ack) queries the ROOT session and reports the wrong naming state.
|
|
2138
|
+
await ensureSession();
|
|
2015
2139
|
const block = await sessionSurfaceBlock();
|
|
2140
|
+
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
2016
2141
|
// Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
|
|
2017
2142
|
// enough; without an explicit instruction agents rarely say which session they are, so users
|
|
2018
2143
|
// can't match them to their tab on the Drafted surface.
|
|
2019
|
-
const instruction = block.
|
|
2020
|
-
? `You
|
|
2021
|
-
:
|
|
2144
|
+
const instruction = block.nameRequired
|
|
2145
|
+
? `You have NO session name yet — the name-before-work gate blocks all other tools until you set one. Name this session by the work you're doing with the user: a short 2-3 word description (suggested: "${block.suggestedName || '...'}"). Set it now with session(action="name", name="<your name>"), then tell the user you're "<name>".`
|
|
2146
|
+
: block.name
|
|
2147
|
+
? `You are the session named "${block.name}". Tell the user you're "${block.name}" in your reply so they can match you to your tab on the Drafted surface.`
|
|
2148
|
+
: undefined;
|
|
2022
2149
|
return ok({
|
|
2023
2150
|
server: getServerUrl(),
|
|
2151
|
+
ok: mcpUpdate.status !== 'unknown',
|
|
2152
|
+
mcpVersion: PACKAGE_VERSION,
|
|
2153
|
+
mcpUpdate,
|
|
2024
2154
|
editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
|
|
2025
2155
|
agentLabel: getAgentLabel(),
|
|
2026
2156
|
...block,
|
|
@@ -2029,342 +2159,29 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
|
|
|
2029
2159
|
} catch (error) { return err(error); }
|
|
2030
2160
|
});
|
|
2031
2161
|
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
|
|
2036
|
-
|
|
2162
|
+
// Session naming: set/rename THIS agent session's name — the name-before-work gate stays
|
|
2163
|
+
// closed until this succeeds. The name persists server-side (sessions.surface_name), so
|
|
2164
|
+
// reconnects and server restarts keep it and the gate never re-fires for a named session.
|
|
2165
|
+
tool('session', 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes.', {
|
|
2166
|
+
action: z.enum(['name']).describe('Operation — currently only `name` (set/rename this session).'),
|
|
2167
|
+
name: z.string().optional().describe('[name] the session name — a short 2-3 word description of the work (e.g. "beoflow backend"). Max 5 words / 50 chars.'),
|
|
2168
|
+
}, async ({ action, name }) => {
|
|
2169
|
+
if (action !== 'name') return err(new Error(`unknown session action: ${action}`));
|
|
2170
|
+
if (!name || !String(name).trim()) return err(new Error('name required — a short 2-3 word description of the work, e.g. session(action="name", name="beoflow backend")'));
|
|
2037
2171
|
try {
|
|
2038
|
-
const
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
});
|
|
2172
|
+
const result = await api('POST', '/api/sessions/name', { name: String(name) });
|
|
2173
|
+
if (agentSurface) {
|
|
2174
|
+
agentSurface.name = result?.name || String(name).trim();
|
|
2175
|
+
agentSurface.nameRequired = false;
|
|
2176
|
+
agentSurface.capital = ((result?.name || String(name).trim())[0] || '').toUpperCase();
|
|
2177
|
+
}
|
|
2178
|
+
return ok({ sessionId: getState().sessionId, name: result?.name || String(name).trim(), named: true });
|
|
2045
2179
|
} catch (error) { return err(error); }
|
|
2046
2180
|
});
|
|
2047
2181
|
|
|
2048
2182
|
// ── Project management tools (direct HTTP) ────────────────────────
|
|
2049
2183
|
|
|
2050
|
-
tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (bind this agent session to a project; required before reading/writing frames — the org derives from the project), create (new project; org= names where it is born), update (change name/folder/description/layers), move (transfer to another org), export (the project as an OKF v0.1 bundle — <layer>/<lane>/<file>.md concepts, index.md/log.md synthesized), import (ingest an OKF bundle: concepts become markdown document frames, links between them become connectors; dryRun supported). There is no org switching: for project-less work (wiki/skills) pass org=... on the call. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
|
|
2051
|
-
action: z.enum(['list', 'open', 'create', 'update', 'move', 'export', 'import']).describe('Operation to perform.'),
|
|
2052
|
-
projectId: z.string().optional().describe('[open|update|move|export|import] project ID. Get IDs from action=list. For export/import: defaults to the bound project.'),
|
|
2053
|
-
name: z.string().optional().describe('[create|update] project name'),
|
|
2054
|
-
description: z.string().nullable().optional().describe('[create|update] project description'),
|
|
2055
|
-
templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
|
|
2056
|
-
org: z.string().optional().describe('[create] org slug or id the project is born in. Defaults to this session\'s org (the open project\'s org, else the default org).'),
|
|
2057
|
-
folder: z.string().nullable().optional().describe('[update] folder name (null to remove from folder). Pass "Archive" to archive: the project drops out of search and the active list but is never deleted and can be restored by a human.'),
|
|
2058
|
-
layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
|
|
2059
|
-
targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
|
|
2060
|
-
format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
|
|
2061
|
-
limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
|
|
2062
|
-
offset: z.number().optional().describe('[export] pagination offset for format="files"'),
|
|
2063
|
-
compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
|
|
2064
|
-
files: z.array(z.object({
|
|
2065
|
-
path: z.string().describe('Bundle-relative file path, e.g. "research/default/notes.md"'),
|
|
2066
|
-
content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
|
|
2067
|
-
})).optional().describe('[import] OKF bundle files inline. index.md/log.md are skipped (synthesized). Caps: 500 files, 512KB/file, 5MB total.'),
|
|
2068
|
-
dryRun: z.boolean().optional().describe('[import] preview the {creates, updates, skips, warnings} report without writing'),
|
|
2069
|
-
...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-project-<slug>). import: recursively read .md files from here (alternative to files[]).') }),
|
|
2070
|
-
}, async (args) => {
|
|
2071
|
-
try {
|
|
2072
|
-
const { action } = args;
|
|
2073
|
-
switch (action) {
|
|
2074
|
-
case 'list': {
|
|
2075
|
-
const data = await api('GET', '/api/projects');
|
|
2076
|
-
data.agentProject = getState().projectId || null;
|
|
2077
|
-
try {
|
|
2078
|
-
const favData = await api('GET', '/api/skills/favorites');
|
|
2079
|
-
const favs = favData.skills || [];
|
|
2080
|
-
if (favs.length > 0) {
|
|
2081
|
-
data.favoritedSkills = favs.map(s => ({
|
|
2082
|
-
id: s.id,
|
|
2083
|
-
name: s.name,
|
|
2084
|
-
slug: s.slug,
|
|
2085
|
-
description: s.description,
|
|
2086
|
-
tags: s.tags,
|
|
2087
|
-
}));
|
|
2088
|
-
}
|
|
2089
|
-
} catch { /* skills not available */ }
|
|
2090
|
-
|
|
2091
|
-
const structuredContent = {
|
|
2092
|
-
projects: (data.projects || []).map(p => ({
|
|
2093
|
-
id: p.id,
|
|
2094
|
-
name: p.name,
|
|
2095
|
-
slug: p.slug,
|
|
2096
|
-
description: p.description,
|
|
2097
|
-
orgId: p.orgId,
|
|
2098
|
-
})),
|
|
2099
|
-
activeProject: data.agentProject,
|
|
2100
|
-
};
|
|
2101
|
-
return ok(data, { structuredContent });
|
|
2102
|
-
}
|
|
2103
|
-
case 'open': {
|
|
2104
|
-
const ref = args.projectId;
|
|
2105
|
-
if (!ref) throw new Error('projectId required for action=open (UUID, slug, or name)');
|
|
2106
|
-
// Accept a slug or name, not just a UUID — the slug is what an agent actually has
|
|
2107
|
-
// in context after a reconnect.
|
|
2108
|
-
const resolved = await resolveProjectRef(ref).catch(() => null);
|
|
2109
|
-
const projectId = resolved?.id || ref;
|
|
2110
|
-
const result = await api('POST', '/api/project/switch', { projectId });
|
|
2111
|
-
joinAgentWsRoom(projectId);
|
|
2112
|
-
const base = getServerUrl();
|
|
2113
|
-
const projectMeta = resolved || { id: projectId, slug: null, name: null, orgId: null, orgSlug: null };
|
|
2114
|
-
const projectSlug = projectMeta.slug || projectId;
|
|
2115
|
-
const orgSlug = projectMeta.orgSlug || null;
|
|
2116
|
-
setMcpActiveProject(projectId, projectMeta);
|
|
2117
|
-
// Semantic /o/<org-slug>/<project-slug> when the org slug is known; otherwise
|
|
2118
|
-
// the /project/<slug> resolver redirects to it.
|
|
2119
|
-
const url = orgSlug ? `${base}/o/${encodeURIComponent(orgSlug)}/${encodeURIComponent(projectSlug)}` : `${base}/project/${projectSlug}`;
|
|
2120
|
-
// Surfacing to the user is the focus mechanism's job now (agent-active ping ->
|
|
2121
|
-
// desktop window / notification+glow for browser tabs) — this used to also force
|
|
2122
|
-
// `exec('open <url>')` on the MCP host machine, an unsolicited GUI action that both
|
|
2123
|
-
// duplicated the focus mechanism and did nothing useful for remote/hosted MCP mode
|
|
2124
|
-
// (no GUI to open on Drafted's own server). `url` is still returned below for the
|
|
2125
|
-
// agent/human to open manually.
|
|
2126
|
-
let navigated = 0;
|
|
2127
|
-
try {
|
|
2128
|
-
const nav = await api('POST', '/api/project/navigate', { projectId });
|
|
2129
|
-
navigated = nav.navigated || 0;
|
|
2130
|
-
} catch { /* server may not support navigate yet */ }
|
|
2131
|
-
// G4/G5 auto-inject (locked design): the project's attached skills + anchors
|
|
2132
|
-
// are pushed into the open response within the per-project context budget,
|
|
2133
|
-
// replacing the reject-style gate. Prefer the server-computed `priming`
|
|
2134
|
-
// (authoritative + fresh on deploy); fall back to MCP-side assembly for
|
|
2135
|
-
// older servers that don't return it.
|
|
2136
|
-
let responseExtras;
|
|
2137
|
-
const priming = result && result.priming ? result.priming : null;
|
|
2138
|
-
if (priming) {
|
|
2139
|
-
const primedSkills = priming.skills || [];
|
|
2140
|
-
const primedAnchors = priming.anchors || [];
|
|
2141
|
-
for (const s of primedSkills) getSessionState().loadedSkillIds.add(s.id);
|
|
2142
|
-
responseExtras = { url, opened: true, navigated, skills: primedSkills, anchors: primedAnchors };
|
|
2143
|
-
if (priming.budgetNotice) responseExtras.budgetNotice = priming.budgetNotice;
|
|
2144
|
-
} else {
|
|
2145
|
-
let projectSkillsList = [];
|
|
2146
|
-
try {
|
|
2147
|
-
const skillData = await api('GET', `/api/projects/${projectId}/skills`);
|
|
2148
|
-
projectSkillsList = skillData.skills || [];
|
|
2149
|
-
} catch { /* skills not available yet */ }
|
|
2150
|
-
for (const s of projectSkillsList) {
|
|
2151
|
-
try {
|
|
2152
|
-
const full = await api('GET', `/api/skills/${s.id}`);
|
|
2153
|
-
s.content = full.content;
|
|
2154
|
-
s.files = full.files || [];
|
|
2155
|
-
} catch { /* leaves this skill without inlined content */ }
|
|
2156
|
-
}
|
|
2157
|
-
let anchors = [];
|
|
2158
|
-
try {
|
|
2159
|
-
const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
|
|
2160
|
-
anchors = (Array.isArray(anchored) ? anchored : []).map(a => ({
|
|
2161
|
-
id: a.id,
|
|
2162
|
-
path: a.path || `/${a.layer || ''}/${a.lane || ''}/${a.label || ''}`,
|
|
2163
|
-
layer: a.layer,
|
|
2164
|
-
content: a.content || '',
|
|
2165
|
-
}));
|
|
2166
|
-
} catch { /* anchors unavailable */ }
|
|
2167
|
-
const sel = selectWithinBudget([...projectSkillsList, ...anchors], PROJECT_CONTEXT_BUDGET_CHARS);
|
|
2168
|
-
for (const it of sel.included) {
|
|
2169
|
-
if (projectSkillsList.includes(it)) getSessionState().loadedSkillIds.add(it.id);
|
|
2170
|
-
}
|
|
2171
|
-
for (const it of sel.deferred) { it.content = undefined; if ('files' in it) it.files = undefined; }
|
|
2172
|
-
responseExtras = { url, opened: true, navigated, skills: projectSkillsList, anchors };
|
|
2173
|
-
if (sel.deferred.length > 0) {
|
|
2174
|
-
responseExtras.budgetNotice =
|
|
2175
|
-
`${sel.deferred.length} attached skill(s)/anchor(s) exceeded the per-project context budget ` +
|
|
2176
|
-
`(${PROJECT_CONTEXT_BUDGET_CHARS} chars) and were not inlined — load explicitly if needed: ` +
|
|
2177
|
-
sel.deferred.map(it => it.slug || it.path || it.id).join(', ');
|
|
2178
|
-
}
|
|
2179
|
-
}
|
|
2180
|
-
// Don't echo the raw priming blob (surfaced via skills/anchors/budgetNotice).
|
|
2181
|
-
if (result && typeof result === 'object') delete result.priming;
|
|
2182
|
-
return ok({ ...result, ...responseExtras });
|
|
2183
|
-
}
|
|
2184
|
-
case 'create': {
|
|
2185
|
-
const g3 = g3Block(getSessionState().gates);
|
|
2186
|
-
if (g3) return err(new Error(g3));
|
|
2187
|
-
const { name, description, templateSlug, org } = args;
|
|
2188
|
-
if (!name) throw new Error('name required for action=create');
|
|
2189
|
-
// Don't silently create the project in whatever org the session inherited.
|
|
2190
|
-
await requireBoundOrgForProjectlessMutation(org);
|
|
2191
|
-
const body = { name };
|
|
2192
|
-
if (description) body.description = description;
|
|
2193
|
-
if (templateSlug) body.templateSlug = templateSlug;
|
|
2194
|
-
// `org` targets a specific org without switching the active org.
|
|
2195
|
-
const createExtra = org ? { 'X-Drafted-Org': org } : {};
|
|
2196
|
-
const created = await api('POST', '/api/projects', body, createExtra);
|
|
2197
|
-
return ok({ ...withProjectBreadcrumb(created), ...(await orgEcho(created, org)) });
|
|
2198
|
-
}
|
|
2199
|
-
case 'update': {
|
|
2200
|
-
const { projectId, name, folder, description, layers } = args;
|
|
2201
|
-
if (!projectId) throw new Error('projectId required for action=update');
|
|
2202
|
-
const body = {};
|
|
2203
|
-
if (name !== undefined) body.name = name;
|
|
2204
|
-
if (folder !== undefined) {
|
|
2205
|
-
// "Archive" is the agent-facing name for the reserved __archived folder:
|
|
2206
|
-
// archived projects drop out of search and the active list but are never
|
|
2207
|
-
// deleted, so an agent can safely archive a project it created in error
|
|
2208
|
-
// and a human can always restore it. Pass any other folder name to file,
|
|
2209
|
-
// or null to remove from a folder.
|
|
2210
|
-
body.folder = folder === 'Archive' ? '__archived' : folder;
|
|
2211
|
-
}
|
|
2212
|
-
if (description !== undefined) body.description = description;
|
|
2213
|
-
if (layers) body.layers = layers;
|
|
2214
|
-
if (Object.keys(body).length === 0) throw new Error('At least one field (name, folder, description, layers) is required for action=update');
|
|
2215
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, body));
|
|
2216
|
-
}
|
|
2217
|
-
case 'move': {
|
|
2218
|
-
const { projectId, targetOrgId } = args;
|
|
2219
|
-
if (!projectId || !targetOrgId) throw new Error('projectId and targetOrgId required for action=move');
|
|
2220
|
-
return ok(await api('POST', `/api/project/${projectId}/move`, { targetOrgId }));
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
// ── export ──────────────────────────────────────────────────
|
|
2224
|
-
// The project as an OKF v0.1 bundle. Mirrors wiki export: format="files"
|
|
2225
|
-
// pages the bundle inline; otherwise stdio writes a local dir, remote
|
|
2226
|
-
// returns the authenticated tar.gz download URL.
|
|
2227
|
-
case 'export': {
|
|
2228
|
-
const projectId = args.projectId || getState().projectId;
|
|
2229
|
-
if (!projectId) throw new Error('projectId required for action=export (or open a project first)');
|
|
2230
|
-
if (args.format === 'files') {
|
|
2231
|
-
const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
|
|
2232
|
-
if (args.offset) qp.set('offset', String(args.offset));
|
|
2233
|
-
if (args.compact) qp.set('compact', 'true');
|
|
2234
|
-
return ok(await api('GET', `/api/projects/${projectId}/export?${qp.toString()}`));
|
|
2235
|
-
}
|
|
2236
|
-
if (isRemote) {
|
|
2237
|
-
return ok({
|
|
2238
|
-
downloadUrl: `${getServerUrl()}/api/projects/${projectId}/export.tar.gz`,
|
|
2239
|
-
note: 'Open the URL in a signed-in browser to download the OKF v0.1 project bundle, or call export with format="files" to page the bundle contents inline.',
|
|
2240
|
-
});
|
|
2241
|
-
}
|
|
2242
|
-
// stdio: write every bundle file under a local directory.
|
|
2243
|
-
const meta = getCurrentProjectContext();
|
|
2244
|
-
const projSlug = String((meta && meta.id === projectId && (meta.slug || meta.name)) || projectId)
|
|
2245
|
-
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
|
|
2246
|
-
const exportDir = resolve(args.dir || `./okf-project-${projSlug}`);
|
|
2247
|
-
let expOffset = 0;
|
|
2248
|
-
let written = 0;
|
|
2249
|
-
for (;;) {
|
|
2250
|
-
const batch = await api('GET', `/api/projects/${projectId}/export?limit=200&offset=${expOffset}`);
|
|
2251
|
-
const batchFiles = batch.files || [];
|
|
2252
|
-
for (const f of batchFiles) {
|
|
2253
|
-
const dest = resolve(exportDir, f.path);
|
|
2254
|
-
if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
|
|
2255
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
2256
|
-
writeFileSync(dest, f.content, 'utf8');
|
|
2257
|
-
written++;
|
|
2258
|
-
}
|
|
2259
|
-
expOffset += batchFiles.length;
|
|
2260
|
-
if (!batch.truncated || batchFiles.length === 0) break;
|
|
2261
|
-
}
|
|
2262
|
-
return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 project bundle written (<layer>/<lane>/<file>.md concepts; index.md and log.md are synthesized).' });
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
// ── import ──────────────────────────────────────────────────
|
|
2266
|
-
// Ingest an OKF bundle into the project: inline files[] or (stdio) a
|
|
2267
|
-
// local dir walked for .md files. Concepts become markdown document
|
|
2268
|
-
// frames; markdown links between them become connectors.
|
|
2269
|
-
case 'import': {
|
|
2270
|
-
const projectId = args.projectId || getState().projectId;
|
|
2271
|
-
if (!projectId) throw new Error('projectId required for action=import (or open a project first)');
|
|
2272
|
-
let importFiles = args.files;
|
|
2273
|
-
if (!importFiles && args.dir) {
|
|
2274
|
-
const root = resolve(args.dir);
|
|
2275
|
-
if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
|
|
2276
|
-
importFiles = [];
|
|
2277
|
-
const walk = (d) => {
|
|
2278
|
-
for (const ent of readdirSync(d, { withFileTypes: true })) {
|
|
2279
|
-
if (ent.name.startsWith('.')) continue;
|
|
2280
|
-
const p = join(d, ent.name);
|
|
2281
|
-
if (ent.isDirectory()) walk(p);
|
|
2282
|
-
else if (/\.md$/i.test(ent.name)) {
|
|
2283
|
-
if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
|
|
2284
|
-
importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
|
|
2285
|
-
}
|
|
2286
|
-
}
|
|
2287
|
-
};
|
|
2288
|
-
walk(root);
|
|
2289
|
-
}
|
|
2290
|
-
if (!Array.isArray(importFiles) || importFiles.length === 0) {
|
|
2291
|
-
throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
|
|
2292
|
-
}
|
|
2293
|
-
return ok(await api('POST', `/api/projects/${projectId}/import`, { files: importFiles, dryRun: !!args.dryRun }));
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
default:
|
|
2297
|
-
throw new Error(`Unknown project action: ${action}`);
|
|
2298
|
-
}
|
|
2299
|
-
} catch (error) { return err(error); }
|
|
2300
|
-
});
|
|
2301
2184
|
|
|
2302
|
-
tool('template', 'Manage project templates in an org. Dispatch by `action`: list/create/update/delete/fork. A template bundles layer definitions and an optional list of skill slugs that auto-attach to projects created from it. Scopes to the open project\'s org by default; pass org=... to target another org (per-request only — nothing is switched).', {
|
|
2303
|
-
action: z.enum(['list', 'create', 'update', 'delete', 'fork']).describe('Operation to perform.'),
|
|
2304
|
-
templateId: z.string().optional().describe('[update|delete|fork] template ID'),
|
|
2305
|
-
name: z.string().optional().describe('[create|update|fork] template name (required for create; optional rename for fork)'),
|
|
2306
|
-
description: z.string().optional().describe('[create|update] template description'),
|
|
2307
|
-
layers: z.array(z.object({}).passthrough()).optional().describe('[create|update] array of layer definitions'),
|
|
2308
|
-
skillSlugs: z.array(z.string()).optional().describe('[create|update] skill slugs to auto-attach to projects created from this template. Slugs are resolved against the org\'s skills (org-local first, built-in fallback) at project-create time; missing slugs are silently skipped. Pass an empty array on update to clear.'),
|
|
2309
|
-
visibility: z.string().optional().describe('[create|update] "org" or "public"'),
|
|
2310
|
-
compact: z.boolean().optional().describe('[list] default true — return {id,slug,name,description,layerCount,skillSlugs} only. Pass false for full layer definitions.'),
|
|
2311
|
-
limit: z.number().optional().describe('[list] max templates to return (default 25, max 100).'),
|
|
2312
|
-
offset: z.number().optional().describe('[list] pagination offset.'),
|
|
2313
|
-
org: z.string().optional().describe('Org (id or name) to scope this call to — defaults to the open project\'s org. Per-request only.'),
|
|
2314
|
-
}, async (args) => {
|
|
2315
|
-
try {
|
|
2316
|
-
const { action } = args;
|
|
2317
|
-
const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
|
|
2318
|
-
switch (action) {
|
|
2319
|
-
case 'list': {
|
|
2320
|
-
const tpls = await api('GET', '/api/templates', undefined, orgHeader);
|
|
2321
|
-
markSearched(getSessionState().gates, 'template');
|
|
2322
|
-
return ok(shapeTemplateCatalog(tpls, { limit: args.limit, offset: args.offset, compact: args.compact !== false }));
|
|
2323
|
-
}
|
|
2324
|
-
case 'create': {
|
|
2325
|
-
const { name, description, layers, skillSlugs, visibility } = args;
|
|
2326
|
-
if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
|
|
2327
|
-
// Don't silently create the template in whatever org the session inherited.
|
|
2328
|
-
await requireBoundOrgForProjectlessMutation(args.org);
|
|
2329
|
-
const body = { name, description, layers };
|
|
2330
|
-
if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
|
|
2331
|
-
if (visibility) body.visibility = visibility;
|
|
2332
|
-
const createdTpl = await api('POST', '/api/templates', body, orgHeader);
|
|
2333
|
-
return ok({ ...createdTpl, ...(await orgEcho(createdTpl, args.org)) });
|
|
2334
|
-
}
|
|
2335
|
-
case 'update': {
|
|
2336
|
-
const { templateId, name, description, layers, skillSlugs, visibility } = args;
|
|
2337
|
-
if (!templateId) throw new Error('templateId required for action=update');
|
|
2338
|
-
const body = {};
|
|
2339
|
-
if (name) body.name = name;
|
|
2340
|
-
if (description) body.description = description;
|
|
2341
|
-
if (layers) body.layers = layers;
|
|
2342
|
-
if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
|
|
2343
|
-
if (visibility) body.visibility = visibility;
|
|
2344
|
-
if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
|
|
2345
|
-
const updatedTpl = await api('PUT', `/api/templates/${templateId}`, body, orgHeader);
|
|
2346
|
-
return ok({ ...updatedTpl, ...(await orgEcho(updatedTpl, args.org)) });
|
|
2347
|
-
}
|
|
2348
|
-
case 'delete': {
|
|
2349
|
-
const { templateId } = args;
|
|
2350
|
-
if (!templateId) throw new Error('templateId required for action=delete');
|
|
2351
|
-
return ok(await api('DELETE', `/api/templates/${templateId}`, undefined, orgHeader));
|
|
2352
|
-
}
|
|
2353
|
-
case 'fork': {
|
|
2354
|
-
const { templateId, name } = args;
|
|
2355
|
-
if (!templateId) throw new Error('templateId required for action=fork');
|
|
2356
|
-
// Forking creates a new template — same org rule as any create.
|
|
2357
|
-
await requireBoundOrgForProjectlessMutation(args.org);
|
|
2358
|
-
const body = {};
|
|
2359
|
-
if (name) body.name = name;
|
|
2360
|
-
const forkedTpl = await api('POST', `/api/templates/${templateId}/fork`, body, orgHeader);
|
|
2361
|
-
return ok({ ...forkedTpl, ...(await orgEcho(forkedTpl, args.org)) });
|
|
2362
|
-
}
|
|
2363
|
-
default:
|
|
2364
|
-
throw new Error(`Unknown template action: ${action}`);
|
|
2365
|
-
}
|
|
2366
|
-
} catch (error) { return err(error); }
|
|
2367
|
-
});
|
|
2368
2185
|
|
|
2369
2186
|
tool('trigger', {
|
|
2370
2187
|
action: z.enum(['create', 'list', 'update', 'rotate', 'test', 'deliveries', 'delete', 'pending', 'claim', 'complete']).describe('Operation to perform.'),
|
|
@@ -2379,28 +2196,35 @@ tool('trigger', {
|
|
|
2379
2196
|
deliveryId: z.string().optional().describe('[complete] delivery ID returned by claim'),
|
|
2380
2197
|
ok: z.boolean().optional().describe('[complete] whether the claimed work succeeded (default true)'),
|
|
2381
2198
|
error: z.string().optional().describe('[complete] error note when ok=false'),
|
|
2382
|
-
projectId: z.string().optional().describe('[create|list|pending]
|
|
2199
|
+
projectId: z.string().optional().describe('[create|list|pending] the project this trigger belongs to — REQUIRED, as a path (/projects/<name> or /projects/<folder>/<name>), slug, or UUID. Triggers are project-scoped: they run agent conversations in that project. No session fallback — name the project.'),
|
|
2383
2200
|
limit: z.number().optional().describe('[deliveries] max rows (default 25, max 100)'),
|
|
2384
2201
|
}, async (args) => {
|
|
2385
2202
|
try {
|
|
2386
2203
|
const { action } = args;
|
|
2204
|
+
// Triggers are project-scoped. Resolve the project from the fs path (or
|
|
2205
|
+
// slug/UUID), scope the API calls via withProjectOverride so api() appends
|
|
2206
|
+
// THIS project (not whatever session is bound), and echo projectPath back.
|
|
2207
|
+
const project = action === 'create' || action === 'list' || action === 'pending' ? await resolveProjectArg(args.projectId) : null;
|
|
2208
|
+
const projectPath = project ? `/projects/${project.slug}` : null;
|
|
2209
|
+
const scoped = (fn) => project ? withProjectOverride(project, fn) : fn();
|
|
2387
2210
|
switch (action) {
|
|
2388
2211
|
case 'create': {
|
|
2389
2212
|
if (!args.name || !args.promptTemplate) throw new Error('name and promptTemplate required for action=create');
|
|
2390
|
-
const body = { name: args.name, promptTemplate: args.promptTemplate };
|
|
2391
|
-
if (args.projectId) body.projectId = args.projectId;
|
|
2213
|
+
const body = { name: args.name, promptTemplate: args.promptTemplate, projectId: project.id };
|
|
2392
2214
|
if (args.executor) body.executor = args.executor;
|
|
2393
2215
|
if (args.signingSecret) body.signingSecret = args.signingSecret;
|
|
2394
2216
|
if (args.dailyLimit !== undefined) body.dailyLimit = args.dailyLimit;
|
|
2395
|
-
const created = await api('POST', '/api/triggers', body);
|
|
2217
|
+
const created = await scoped(() => api('POST', '/api/triggers', body));
|
|
2396
2218
|
return ok({
|
|
2397
2219
|
...created,
|
|
2220
|
+
projectPath,
|
|
2398
2221
|
note: 'Relay `url` (and the HMAC secret if set) to the user NOW — the token is shown once and cannot be retrieved later, only rotated.',
|
|
2399
2222
|
});
|
|
2400
2223
|
}
|
|
2401
2224
|
case 'list': {
|
|
2402
|
-
const
|
|
2403
|
-
|
|
2225
|
+
const result = await scoped(() => api('GET', '/api/triggers'));
|
|
2226
|
+
const triggers = (result.triggers || []).map(t => ({ ...t, projectPath }));
|
|
2227
|
+
return ok({ ...result, triggers, projectPath });
|
|
2404
2228
|
}
|
|
2405
2229
|
case 'update': {
|
|
2406
2230
|
if (!args.triggerId) throw new Error('triggerId required for action=update');
|
|
@@ -2434,8 +2258,7 @@ tool('trigger', {
|
|
|
2434
2258
|
return ok(await api('DELETE', `/api/triggers/${args.triggerId}`));
|
|
2435
2259
|
}
|
|
2436
2260
|
case 'pending': {
|
|
2437
|
-
|
|
2438
|
-
return ok(await api('GET', `/api/triggers/pending${qs}`));
|
|
2261
|
+
return ok(await scoped(() => api('GET', '/api/triggers/pending').then(r => ({ ...r, projectPath }))));
|
|
2439
2262
|
}
|
|
2440
2263
|
case 'claim': {
|
|
2441
2264
|
if (!args.triggerId) throw new Error('triggerId required for action=claim');
|
|
@@ -2457,67 +2280,55 @@ tool('trigger', {
|
|
|
2457
2280
|
});
|
|
2458
2281
|
|
|
2459
2282
|
tool('focus', {
|
|
2460
|
-
target: z.string().describe('
|
|
2283
|
+
target: z.string().describe('What to pan the canvas to: a frame path (/projects/<project>/<layer>/<lane>/<file>), a frame URL (any URL containing /f/{uuid} or /o/<org>/projects/...), or a frame ID (UUID). When a user shares a Drafted link, pass it directly here.'),
|
|
2461
2284
|
}, async ({ target }) => {
|
|
2462
2285
|
try {
|
|
2463
|
-
|
|
2464
|
-
const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
|
|
2465
|
-
const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
|
|
2466
|
-
let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
|
|
2467
|
-
|
|
2468
|
-
if (!frameId) {
|
|
2469
|
-
// Path — resolve via read to get the frame ID
|
|
2470
|
-
const parts = target.replace(/^\/+/, '').split('/');
|
|
2471
|
-
if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
|
|
2472
|
-
const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
|
|
2473
|
-
if (!frame.id) throw new Error('Frame not found');
|
|
2474
|
-
frameId = frame.id;
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2286
|
+
const { frameId } = await resolveFsFramePath(target);
|
|
2477
2287
|
const result = await api('POST', `/api/focus/${frameId}`);
|
|
2478
2288
|
return ok(result);
|
|
2479
2289
|
} catch (error) { return err(error); }
|
|
2480
2290
|
});
|
|
2481
2291
|
|
|
2482
|
-
tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a single frame
|
|
2292
|
+
tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a single frame; `scope=canvas` captures a region of the project surface (default 1600×1200, typically the "plans" layer). Targets are pseudo-filesystem paths: `scope=frame` takes /projects/<project>/<layer>/<lane>/<file> (or a frame URL / UUID); `scope=canvas` takes /projects/<project> with an optional `layer`.', {
|
|
2483
2293
|
scope: z.enum(['frame', 'canvas']).describe('What to capture.'),
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
layer: z.string().optional().describe('[scope=canvas] layer key to capture (default: plans).'),
|
|
2294
|
+
path: z.string().optional().describe('[frame] /projects/<project>/<layer>/<lane>/<file>, a frame URL (/f/{uuid} or /o/<org>/projects/...), or a frame UUID. [canvas] /projects/<project> (project root) — captures that project\'s surface.'),
|
|
2295
|
+
layer: z.string().optional().describe('[canvas] layer key to capture (default: plans).'),
|
|
2487
2296
|
width: z.number().optional().describe('Viewport width in pixels (frame default 1440, canvas default 1600).'),
|
|
2488
2297
|
height: z.number().optional().describe('Viewport height in pixels (frame default 900, canvas default 1200).'),
|
|
2489
|
-
fullPage: z.boolean().optional().describe('[
|
|
2298
|
+
fullPage: z.boolean().optional().describe('[frame] capture full page or just viewport (default true).'),
|
|
2490
2299
|
// outputPath is local-only — omitted on remote transports (web MCP) where the host has no caller filesystem.
|
|
2491
2300
|
...(isRemote ? {} : { outputPath: z.string().optional().describe('[stdio only] Absolute local path to write the rendered PNG to. When set, the PNG is saved to disk and the tool returns the path + byte count instead of returning the image inline — use this to get rendered pixels OUT of Drafted (attach to a message, upload, post-process). Parent directories are created if missing.') }),
|
|
2301
|
+
// Legacy aliases (pre-fs-path): kept for installed-client compat.
|
|
2302
|
+
target: z.string().optional().describe('[legacy frame] frame URL, UUID, or /{layer}/{lane}/{filename} path.'),
|
|
2303
|
+
slug: z.string().optional().describe('[legacy canvas] project slug. Defaults to the currently active project.'),
|
|
2492
2304
|
}, async (args) => {
|
|
2493
2305
|
try {
|
|
2494
2306
|
const { scope } = args;
|
|
2495
2307
|
let buffer;
|
|
2496
2308
|
if (scope === 'frame') {
|
|
2497
|
-
const
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
const
|
|
2501
|
-
let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
|
|
2502
|
-
if (!frameId) {
|
|
2503
|
-
const parts = target.replace(/^\/+/, '').split('/');
|
|
2504
|
-
if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
|
|
2505
|
-
const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
|
|
2506
|
-
if (!frame.id) throw new Error('Frame not found');
|
|
2507
|
-
frameId = frame.id;
|
|
2508
|
-
}
|
|
2309
|
+
const target = args.path || args.target;
|
|
2310
|
+
const { width = 1440, height = 900, fullPage = true } = args;
|
|
2311
|
+
if (!target) throw new Error('path required for scope=frame (e.g. /projects/<project>/<layer>/<lane>/<file>)');
|
|
2312
|
+
const { frameId } = await resolveFsFramePath(target);
|
|
2509
2313
|
const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
|
|
2510
2314
|
await ensureSession();
|
|
2511
2315
|
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
2512
2316
|
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
|
|
2513
2317
|
buffer = Buffer.from(await res.arrayBuffer());
|
|
2514
2318
|
} else if (scope === 'canvas') {
|
|
2515
|
-
const {
|
|
2516
|
-
let targetSlug = slug;
|
|
2319
|
+
const { path: canvasPath, layer = 'plans', width = 1600, height = 1200 } = args;
|
|
2320
|
+
let targetSlug = args.slug;
|
|
2321
|
+
if (!targetSlug && canvasPath) {
|
|
2322
|
+
// /o/<org>/projects/<project> or /projects/<project> or /projects/<folder>/<project> → project slug
|
|
2323
|
+
const scoped = splitOrgScope(canvasPath);
|
|
2324
|
+
if (scoped.error) throw new Error(scoped.error);
|
|
2325
|
+
const parts = scoped.path.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
|
|
2326
|
+
targetSlug = parts[parts.length - 1];
|
|
2327
|
+
}
|
|
2517
2328
|
if (!targetSlug) {
|
|
2518
2329
|
const list = await api('GET', '/api/projects');
|
|
2519
2330
|
const active = (list.projects || []).find(p => p.id === list.activeProject);
|
|
2520
|
-
if (!active) throw new Error('No active project — pass
|
|
2331
|
+
if (!active) throw new Error('No active project — pass path=/projects/<project> explicitly.');
|
|
2521
2332
|
targetSlug = active.slug;
|
|
2522
2333
|
}
|
|
2523
2334
|
const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
|
|
@@ -2542,2188 +2353,65 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
|
|
|
2542
2353
|
} catch (error) { return err(error); }
|
|
2543
2354
|
});
|
|
2544
2355
|
|
|
2545
|
-
tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remove/reorder. Layers are the horizontal bands of a Drafted canvas (e.g. wireframes, designs, brand-assets). All actions take projectId; add/update/remove also take the layer `key`.', {
|
|
2546
|
-
action: z.enum(['add', 'update', 'remove', 'reorder']).describe('Operation to perform.'),
|
|
2547
|
-
projectId: z.string().describe('Project ID (all actions require this)'),
|
|
2548
|
-
key: z.string().optional().describe('[add|update|remove] unique layer key (e.g. "research", "prototypes")'),
|
|
2549
|
-
label: z.string().optional().describe('[add|update] display label'),
|
|
2550
|
-
type: z.string().optional().describe('[add|update] layer type (e.g. "html", "image", "text")'),
|
|
2551
|
-
width: z.number().optional().describe('[add|update] default frame width in pixels'),
|
|
2552
|
-
height: z.number().optional().describe('[add|update] default frame height in pixels'),
|
|
2553
|
-
description: z.string().optional().describe('[add|update] layer description'),
|
|
2554
|
-
prompt: z.string().optional().describe('[add|update] prompt hint for AI agents working in this layer'),
|
|
2555
|
-
force: z.boolean().optional().describe('[remove] force removal even if the layer contains frames'),
|
|
2556
|
-
keys: z.array(z.string()).optional().describe('[reorder] ordered array of ALL existing layer keys — no additions, removals, or duplicates'),
|
|
2557
|
-
}, async (args) => {
|
|
2558
|
-
try {
|
|
2559
|
-
const { action, projectId } = args;
|
|
2560
|
-
if (!projectId) throw new Error('projectId is required');
|
|
2561
|
-
switch (action) {
|
|
2562
|
-
case 'add': {
|
|
2563
|
-
const { key, label, type, width, height, description, prompt } = args;
|
|
2564
|
-
if (!key || !label || !type || width == null || height == null) {
|
|
2565
|
-
throw new Error('key, label, type, width, height are required for action=add');
|
|
2566
|
-
}
|
|
2567
|
-
const project = await api('GET', `/api/project/${projectId}`);
|
|
2568
|
-
const layers = project.layers || [];
|
|
2569
|
-
if (layers.some(l => l.key === key)) {
|
|
2570
|
-
throw new Error(`Layer with key "${key}" already exists in this project`);
|
|
2571
|
-
}
|
|
2572
|
-
const newLayer = { key, label, type, width, height };
|
|
2573
|
-
if (description !== undefined) newLayer.description = description;
|
|
2574
|
-
if (prompt !== undefined) newLayer.prompt = prompt;
|
|
2575
|
-
layers.push(newLayer);
|
|
2576
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
|
|
2577
|
-
}
|
|
2578
|
-
case 'update': {
|
|
2579
|
-
const { key, label, type, width, height, description, prompt } = args;
|
|
2580
|
-
if (!key) throw new Error('key is required for action=update');
|
|
2581
|
-
const project = await api('GET', `/api/project/${projectId}`);
|
|
2582
|
-
const layers = project.layers || project.project?.layers;
|
|
2583
|
-
if (!Array.isArray(layers)) throw new Error('Project has no layers array');
|
|
2584
|
-
const idx = layers.findIndex(l => l.key === key);
|
|
2585
|
-
if (idx === -1) throw new Error(`Layer with key "${key}" not found`);
|
|
2586
|
-
const updates = { label, type, width, height, description, prompt };
|
|
2587
|
-
const filtered = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
|
|
2588
|
-
if (Object.keys(filtered).length === 0) throw new Error('At least one field (label, type, width, height, description, prompt) is required for action=update');
|
|
2589
|
-
layers[idx] = { ...layers[idx], ...filtered };
|
|
2590
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
|
|
2591
|
-
}
|
|
2592
|
-
case 'remove': {
|
|
2593
|
-
const { key, force = false } = args;
|
|
2594
|
-
if (!key) throw new Error('key is required for action=remove');
|
|
2595
|
-
const project = await api('GET', `/api/project/${projectId}`);
|
|
2596
|
-
const layers = project.layers || [];
|
|
2597
|
-
if (!layers.some(l => l.key === key)) {
|
|
2598
|
-
throw new Error(`Layer with key "${key}" does not exist in this project`);
|
|
2599
|
-
}
|
|
2600
|
-
if (!force) {
|
|
2601
|
-
const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
|
|
2602
|
-
const entries = listing.entries || [];
|
|
2603
|
-
const frames = entries.filter(e => e.type === 'frame');
|
|
2604
|
-
const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md') && !f.path.endsWith('/instructions/AGENTS.md') && !f.path.endsWith('/AGENTS.md'));
|
|
2605
|
-
const frameCount = realFrames.length;
|
|
2606
|
-
if (frameCount > 0) {
|
|
2607
|
-
throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
const filtered = layers.filter(l => l.key !== key);
|
|
2611
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers: filtered }));
|
|
2612
|
-
}
|
|
2613
|
-
case 'reorder': {
|
|
2614
|
-
const { keys } = args;
|
|
2615
|
-
if (!Array.isArray(keys)) throw new Error('keys (array) is required for action=reorder');
|
|
2616
|
-
const project = await api('GET', `/api/project/${projectId}`);
|
|
2617
|
-
const currentLayers = project.layers || [];
|
|
2618
|
-
const currentKeys = currentLayers.map(l => l.key);
|
|
2619
|
-
const uniqueKeys = new Set(keys);
|
|
2620
|
-
if (uniqueKeys.size !== keys.length) {
|
|
2621
|
-
const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
2622
|
-
throw new Error(`Duplicate keys: ${[...new Set(dupes)].join(', ')}`);
|
|
2623
|
-
}
|
|
2624
|
-
const missing = currentKeys.filter(k => !uniqueKeys.has(k));
|
|
2625
|
-
if (missing.length > 0) {
|
|
2626
|
-
throw new Error(`Missing keys: ${missing.join(', ')}. You must include all existing layer keys.`);
|
|
2627
|
-
}
|
|
2628
|
-
const currentSet = new Set(currentKeys);
|
|
2629
|
-
const extra = keys.filter(k => !currentSet.has(k));
|
|
2630
|
-
if (extra.length > 0) {
|
|
2631
|
-
throw new Error(`Unknown keys: ${extra.join(', ')}. Only existing layer keys are allowed.`);
|
|
2632
|
-
}
|
|
2633
|
-
const reorderedLayers = keys.map(k => currentLayers.find(l => l.key === k));
|
|
2634
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers: reorderedLayers }));
|
|
2635
|
-
}
|
|
2636
|
-
default:
|
|
2637
|
-
throw new Error(`Unknown layer action: ${action}`);
|
|
2638
|
-
}
|
|
2639
|
-
} catch (error) { return err(error); }
|
|
2640
|
-
});
|
|
2641
2356
|
|
|
2642
2357
|
|
|
2643
|
-
async function getGoogleDriveAvailability() {
|
|
2644
|
-
try {
|
|
2645
|
-
const status = await api('GET', '/api/google/status');
|
|
2646
|
-
return {
|
|
2647
|
-
connected: !!status?.connected,
|
|
2648
|
-
syncEnabled: !!status?.syncEnabled,
|
|
2649
|
-
driveRootFolderId: status?.driveRootFolderId || null,
|
|
2650
|
-
driveRootFolderName: status?.driveRootFolderName || null,
|
|
2651
|
-
workspaceFramesAvailable: !!status?.connected,
|
|
2652
|
-
preference: status?.connected
|
|
2653
|
-
? 'Strongly prefer Google Workspace frames for docs, sheets, and slides in this org.'
|
|
2654
|
-
: 'Google Drive is not connected; use normal Drafted frames.',
|
|
2655
|
-
};
|
|
2656
|
-
} catch {
|
|
2657
|
-
return {
|
|
2658
|
-
connected: false,
|
|
2659
|
-
syncEnabled: false,
|
|
2660
|
-
driveRootFolderId: null,
|
|
2661
|
-
driveRootFolderName: null,
|
|
2662
|
-
workspaceFramesAvailable: false,
|
|
2663
|
-
preference: 'Google Drive status unavailable; use normal Drafted frames unless a Google Workspace frame succeeds.',
|
|
2664
|
-
};
|
|
2665
|
-
}
|
|
2666
|
-
}
|
|
2667
2358
|
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
// ── Skill library tool ───────────────────────────────────────────
|
|
2364
|
+
|
|
2365
|
+
// Walk a local source tree for `skill action=push` with dir, pre-filtering heavy
|
|
2366
|
+
// dirs + any .skillignore as a convenience so we don't ship a node_modules tree
|
|
2367
|
+
// over the wire. The SERVER re-runs the authoritative hygiene pipeline. Mirrors the
|
|
2368
|
+
// CLI's collectSkillTree.
|
|
2369
|
+
function collectSkillTreeForPush(dir) {
|
|
2370
|
+
const root = resolve(dir);
|
|
2371
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
|
|
2372
|
+
const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform', '.skillinstall']);
|
|
2373
|
+
const NUL = String.fromCharCode(0);
|
|
2374
|
+
let ignore = () => false;
|
|
2375
|
+
const ign = join(root, '.skillignore');
|
|
2376
|
+
if (existsSync(ign)) {
|
|
2377
|
+
const pats = readFileSync(ign, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
2378
|
+
ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
|
|
2379
|
+
}
|
|
2380
|
+
const out = [];
|
|
2381
|
+
const walk = (abs, rel) => {
|
|
2382
|
+
for (const name of readdirSync(abs)) {
|
|
2383
|
+
const childAbs = join(abs, name);
|
|
2384
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
2385
|
+
const st = statSync(childAbs);
|
|
2386
|
+
if (st.isDirectory()) { if (SKIP.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
|
|
2387
|
+
else if (st.isFile()) {
|
|
2388
|
+
if (ignore(childRel)) continue;
|
|
2389
|
+
const content = readFileSync(childAbs, 'utf8');
|
|
2390
|
+
if (content.includes(NUL)) continue; // skip binary; server rejects it anyway
|
|
2391
|
+
out.push({ path: childRel, content });
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2696
2394
|
};
|
|
2395
|
+
walk(root, '');
|
|
2396
|
+
return out;
|
|
2697
2397
|
}
|
|
2698
2398
|
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2399
|
+
// Ensure a pushed source tree's .gitignore excludes the rebuildable bundle dir, so
|
|
2400
|
+
// a skill's machine-specific build output (built into .skillinstall/ by its `setup:`
|
|
2401
|
+
// recipe) can never be committed. The server also strips .skillinstall/ from the
|
|
2402
|
+
// bundle (skill-ingest DENY_DIRS); this keeps the author's git clean. Idempotent.
|
|
2403
|
+
function ensureSkillInstallIgnored(dir) {
|
|
2404
|
+
try {
|
|
2405
|
+
const gi = join(dir, '.gitignore');
|
|
2406
|
+
const existing = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
2407
|
+
if (existing.split(/\r?\n/).some((l) => l.trim().replace(/\/$/, '') === '.skillinstall')) return false;
|
|
2408
|
+
const body = existing && !existing.endsWith('\n') ? existing + '\n' : existing;
|
|
2409
|
+
writeFileSync(gi, body + '.skillinstall/\n');
|
|
2410
|
+
return true;
|
|
2411
|
+
} catch { return false; }
|
|
2412
|
+
}
|
|
2705
2413
|
|
|
2706
|
-
if (mode !== 'stdio') {
|
|
2707
|
-
return {
|
|
2708
|
-
action: 'update_mcp',
|
|
2709
|
-
started: false,
|
|
2710
|
-
updateSupported: false,
|
|
2711
|
-
mode,
|
|
2712
|
-
currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
|
|
2713
|
-
latestVersion: updateMetadata?.latestVersion || null,
|
|
2714
|
-
updateAvailable: false,
|
|
2715
|
-
required: false,
|
|
2716
|
-
command: null,
|
|
2717
|
-
dryRunCommand: null,
|
|
2718
|
-
manualCommand: null,
|
|
2719
|
-
restart: {
|
|
2720
|
-
required: false,
|
|
2721
|
-
guidance: restart.guidance || 'Hosted HTTP MCP updates with the Drafted server deploy.',
|
|
2722
|
-
},
|
|
2723
|
-
note: 'This session is using hosted HTTP MCP, so there is no npm-installed stdio daemon to update on this machine.',
|
|
2724
|
-
};
|
|
2725
|
-
}
|
|
2726
2414
|
|
|
2727
|
-
const server = getServerUrl().replace(/\/$/, '');
|
|
2728
|
-
const manualCommand = platform() === 'win32'
|
|
2729
|
-
? `$tmp = Join-Path $env:TEMP "drafted-install.ps1"; Invoke-WebRequest -UseBasicParsing "${server}/install.ps1" -OutFile $tmp; powershell -NoProfile -ExecutionPolicy Bypass -File $tmp`
|
|
2730
|
-
: `tmp=$(mktemp); curl -fsSL ${server}/install.sh -o "$tmp" && bash "$tmp"`;
|
|
2731
|
-
|
|
2732
|
-
return {
|
|
2733
|
-
action: 'update_mcp',
|
|
2734
|
-
started: false,
|
|
2735
|
-
updateSupported: true,
|
|
2736
|
-
mode: 'stdio',
|
|
2737
|
-
currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
|
|
2738
|
-
latestVersion: updateMetadata?.latestVersion || null,
|
|
2739
|
-
recommendedVersion: updateMetadata?.recommendedVersion || null,
|
|
2740
|
-
minimumRequiredVersion: updateMetadata?.minimumRequiredVersion || null,
|
|
2741
|
-
updateAvailable: !!updateMetadata?.updateAvailable,
|
|
2742
|
-
required: !!updateMetadata?.required,
|
|
2743
|
-
command: 'drafted update --yes',
|
|
2744
|
-
dryRunCommand: 'drafted update --dry-run',
|
|
2745
|
-
manualCommand,
|
|
2746
|
-
restart: {
|
|
2747
|
-
required: true,
|
|
2748
|
-
guidance: restart.guidance || 'Restart agents after updating the npm-installed Drafted MCP daemon.',
|
|
2749
|
-
},
|
|
2750
|
-
note: 'This action is intentionally advisory: it does not replace the currently running MCP process. Run the command, then restart your agent/editor so it starts the updated drafted-mcp.',
|
|
2751
|
-
mcpUpdate: updateMetadata || null,
|
|
2752
|
-
};
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
async function getMcpUpdateMetadata() {
|
|
2756
|
-
const mode = mcpMode();
|
|
2757
|
-
try {
|
|
2758
|
-
const query = new URLSearchParams({
|
|
2759
|
-
cliVersion: PACKAGE_VERSION,
|
|
2760
|
-
mcpMode: mode,
|
|
2761
|
-
});
|
|
2762
|
-
const policy = await api('GET', `/api/installations/latest?${query.toString()}`);
|
|
2763
|
-
return normalizeMcpUpdatePolicy(policy);
|
|
2764
|
-
} catch (error) {
|
|
2765
|
-
return {
|
|
2766
|
-
status: 'unknown',
|
|
2767
|
-
currentVersion: PACKAGE_VERSION,
|
|
2768
|
-
latestVersion: null,
|
|
2769
|
-
recommendedVersion: null,
|
|
2770
|
-
minimumRequiredVersion: null,
|
|
2771
|
-
severity: 'unknown',
|
|
2772
|
-
updateAvailable: false,
|
|
2773
|
-
stale: false,
|
|
2774
|
-
required: false,
|
|
2775
|
-
reason: 'latest_check_failed',
|
|
2776
|
-
enabled: false,
|
|
2777
|
-
mode,
|
|
2778
|
-
distribution: mode === 'stdio' ? 'npm-stdio' : 'hosted-http',
|
|
2779
|
-
update: { command: null, helper: null, packageManager: 'npm' },
|
|
2780
|
-
restart: { required: false, guidance: 'Drafted MCP update status is unavailable; this call still succeeded.' },
|
|
2781
|
-
checkedAt: null,
|
|
2782
|
-
};
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
// Process-lifetime cache: `health` is meant to be called every session, so avoid a network
|
|
2787
|
-
// round-trip on repeat calls. `get_org` shares the cache too (same underlying data).
|
|
2788
|
-
let mcpUpdateCache = null; // { data, fetchedAt }
|
|
2789
|
-
const MCP_UPDATE_CACHE_MS = 5 * 60_000;
|
|
2790
|
-
async function getCachedMcpUpdateMetadata() {
|
|
2791
|
-
if (mcpUpdateCache && (Date.now() - mcpUpdateCache.fetchedAt) < MCP_UPDATE_CACHE_MS) return mcpUpdateCache.data;
|
|
2792
|
-
const data = await getMcpUpdateMetadata();
|
|
2793
|
-
mcpUpdateCache = { data, fetchedAt: Date.now() };
|
|
2794
|
-
return data;
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
tool('get_org', {
|
|
2799
|
-
action: z.enum(['get', 'update_mcp', 'use']).optional().describe('Default: "get" returns your orgs, this session\'s resolved working org, and Google Drive availability. "use" (with org=) sets THIS session\'s working org — a per-session, per-request DEFAULT for project-less creates/forks. It is NOT the retired sticky cursor: it never overrides a UUID-addressed resource, an explicit org=, or an open project, and it is private to this session (never a shared server row). "update_mcp" returns installed stdio MCP update instructions.'),
|
|
2800
|
-
org: z.string().optional().describe('[use] org id or name to set as this session\'s working-org default.'),
|
|
2801
|
-
}, async (args = {}) => {
|
|
2802
|
-
try {
|
|
2803
|
-
const action = args.action || 'get';
|
|
2804
|
-
|
|
2805
|
-
if (action === 'update_mcp') {
|
|
2806
|
-
const mcpUpdate = await getMcpUpdateMetadata();
|
|
2807
|
-
return ok(buildInstalledMcpUpdateInstructions(mcpUpdate));
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
if (action === 'use') {
|
|
2811
|
-
// P3 (DRAFT-36): the explicit per-session working-org. Symmetric with what a remote
|
|
2812
|
-
// connection gets from its OAuth org — a stdio agent declares it here. Stored in
|
|
2813
|
-
// THIS session's bucket only (never a shared server row), injected as X-Drafted-Org
|
|
2814
|
-
// by api(), and only a DEFAULT: a UUID-addressed resource, an explicit org=, or an
|
|
2815
|
-
// open project always win. In-memory (not persisted) so it can never clobber another
|
|
2816
|
-
// process's boot state — the concurrency invariant holds by construction.
|
|
2817
|
-
const want = (args.org || '').trim();
|
|
2818
|
-
if (!want) throw new Error('org (id or name) required for action=use');
|
|
2819
|
-
const orgs = await getOrgList();
|
|
2820
|
-
const byId = orgs.find((o) => o.id === want);
|
|
2821
|
-
const byName = orgs.filter((o) => (o.name || '').toLowerCase() === want.toLowerCase());
|
|
2822
|
-
const hit = byId || (byName.length === 1 ? byName[0] : null);
|
|
2823
|
-
if (!hit) {
|
|
2824
|
-
const reason = byName.length > 1 ? `ambiguous org name "${want}"` : `not a member of org "${want}"`;
|
|
2825
|
-
throw new Error(`${reason}. Your orgs: ${orgs.map((o) => o.name || o.id).join(', ') || '(none resolvable)'}`);
|
|
2826
|
-
}
|
|
2827
|
-
getSessionState().boundOrgId = hit.id;
|
|
2828
|
-
return ok({
|
|
2829
|
-
workingOrg: { id: hit.id, name: hit.name },
|
|
2830
|
-
note: `Working org for this session is now "${hit.name}". Project-less creates/forks default here; a UUID-addressed resource, an explicit org=, or an open project still win. Not the retired switch — per-session and per-request, never a shared cursor.`,
|
|
2831
|
-
});
|
|
2832
|
-
}
|
|
2833
|
-
|
|
2834
|
-
// Source of truth = the org this MCP process scopes requests to (what mutations
|
|
2835
|
-
// will actually hit). Each MCP process is independent — multiple agents can run
|
|
2836
|
-
// in parallel scoped to different orgs. /auth/me reads sessions.org_id directly.
|
|
2837
|
-
const me = await api('GET', '/auth/me');
|
|
2838
|
-
const sessionOrgId = me?.orgId || null;
|
|
2839
|
-
|
|
2840
|
-
const data = await api('GET', '/api/orgs');
|
|
2841
|
-
const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
2842
|
-
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
2843
|
-
|
|
2844
|
-
// This session's WORKING org (P3): where project-less creates/forks land by default.
|
|
2845
|
-
// It's the per-session boundOrgId — set by an open project, a get_org(action="use"),
|
|
2846
|
-
// or (remote) the connection's org — not the shared session cursor. Announce it so an
|
|
2847
|
-
// agent can self-verify without guessing (invariant: "announced, never opaque").
|
|
2848
|
-
// Never REPORT a working org the caller isn't a member of. The old fallback
|
|
2849
|
-
// fabricated `{ id, name: null }` for an unknown id, which is how a stale/foreign
|
|
2850
|
-
// binding read as a real (if nameless) org instead of as the defect it is. If it
|
|
2851
|
-
// isn't in the memberships we just fetched, it can't address anything — clear it
|
|
2852
|
-
// so the session falls back to asking for an explicit org.
|
|
2853
|
-
const sess = getSessionState();
|
|
2854
|
-
const workingOrg = sess.boundOrgId ? (orgs.find(o => o.id === sess.boundOrgId) || null) : null;
|
|
2855
|
-
if (sess.boundOrgId && !workingOrg) sess.boundOrgId = null;
|
|
2856
|
-
|
|
2857
|
-
const googleDrive = await getGoogleDriveAvailability();
|
|
2858
|
-
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
2859
|
-
|
|
2860
|
-
let members = [];
|
|
2861
|
-
if (sessionOrgId) {
|
|
2862
|
-
try {
|
|
2863
|
-
const memberData = await api('GET', `/api/orgs/${sessionOrgId}/members`);
|
|
2864
|
-
members = memberData.members || memberData || [];
|
|
2865
|
-
} catch { /* no members */ }
|
|
2866
|
-
}
|
|
2867
|
-
return ok({
|
|
2868
|
-
activeOrg,
|
|
2869
|
-
workingOrg,
|
|
2870
|
-
orgs,
|
|
2871
|
-
members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
|
|
2872
|
-
googleDrive,
|
|
2873
|
-
mcpVersion: PACKAGE_VERSION,
|
|
2874
|
-
mcpUpdate,
|
|
2875
|
-
session: await sessionSurfaceBlock(),
|
|
2876
|
-
note: "Org is derived from the resource you address: opening a project binds this agent session's context (org = the project's org), and UUIDs (pageId/skillId/projectId) self-derive. `workingOrg` is where project-less creates/forks land by default — set it explicitly with get_org(action=\"use\", org=...) when you're multi-org and working project-less; a UUID/explicit org=/open project always wins. It is per-session and per-request, never a shared cursor or a switch. `session` is THIS agent's own surface identity — `session.name` is the human-readable tab name the user sees (use it to identify which agent you are); refresh it via whoami. Concurrent MCP sessions can operate on different orgs simultaneously. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
|
|
2877
|
-
});
|
|
2878
|
-
} catch (error) { return err(error); }
|
|
2879
|
-
});
|
|
2880
|
-
|
|
2881
|
-
// ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
|
|
2882
|
-
|
|
2883
|
-
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), set_state / get_state (persist or read a deployed windowType:"app" frame\'s hydration state — push e.g. {specText} to drive a generic app frame with data after deploy; the canvas hydrates the app from it on load), Google Sheet actions (`get_sheet`, `read_sheet_values`, `write_sheet_values`, `append_sheet_rows`, `clear_sheet_range`, `update_sheet`), Google Doc actions (`get_doc`, `read_doc_content`, `write_doc_content`, `append_doc_content`, `clear_doc_content`, `update_doc`), Google Slide actions (`get_slide`, `read_slide_content`, `write_slide_content`, `append_slides`, `clear_slides`, `update_slide`), write_excalidraw (native editable Excalidraw diagram — full-scene replace), edit_excalidraw (element-level upsert/remove by id, for growing or surgically editing a large scene without resending it), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Google Workspace native content:** Create or attach Google Docs/Sheets/Slides with `frame(action="write", googleType=...)`. After creating, immediately populate the native file using the matching write action in the same tool — do NOT leave it empty and do NOT tell the user you cannot write to it. For Sheets: `write_sheet_values` or `append_sheet_rows` (pass `path` or `googleId` from the create response). For Docs: `write_doc_content`/`append_doc_content`. For Slides: `write_slide_content`/`append_slides`. Read with `read_sheet_values`/`read_doc_content`/`read_slide_content`. Do NOT use inline `frame.write(content)` or hashline `frame.edit` to populate Google Workspace frames.\n\n**Write — content, binary, or Google Workspace frame:** ' + (isRemote ? 'Provide exactly one of `content` (HTML/markdown/text), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).' : 'Provide exactly one of `content` (HTML/markdown/text), `file_path` (absolute local file), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).') + ' Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for docs, sheets, and slides in that org. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. ' + (isRemote ? 'For binary frames (images, PDFs, videos), pass `base64` with the binary bytes.' : 'For binary frames (images, PDFs, videos), use `file_path` when the file is local to the MCP host, or `base64` when the caller already has binary bytes.') + '\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
|
|
2884
|
-
projectId: PROJECT_OVERRIDE_PARAM,
|
|
2885
|
-
action: z.enum(['read', 'write', 'set_state', 'get_state', 'write_sheet_values', 'read_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'get_sheet', 'update_sheet', 'get_doc', 'read_doc_content', 'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc', 'get_slide', 'read_slide_content', 'write_slide_content', 'append_slides', 'clear_slides', 'update_slide', 'create_office', 'read_office', 'edit_office', 'write_excalidraw', 'edit_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform. Use native Doc/Slide actions for Google Docs/Slides; do not use inline write/edit for native Workspace content.'),
|
|
2886
|
-
path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
|
|
2887
|
-
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
2888
|
-
content: z.string().optional().describe('[write] HTML/markdown/text for Drafted inline frames. [write_doc_content|append_doc_content] native Google Doc body text. Do not use action=write content to populate Google Doc/Slide frames.'),
|
|
2889
|
-
excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene. EVERY write_excalidraw REPLACES THE WHOLE SCENE — it does not merge; to grow a diagram, either resend all prior elements plus the new ones, or use action="edit_excalidraw" (element-level upsert/remove by id) which needs only the changed elements. WRITE LEAN SCENES: your MCP client caps tool-call arguments (a big scene is rejected before Drafted ever sees it — once a scene exceeds that cap, edit it with edit_excalidraw, not write_excalidraw), so emit only the fields that carry meaning — id, type, x, y, width, height, angle, text/label, strokeColor, backgroundColor, fillStyle, strokeWidth, and the binding ids for arrows. Omit every field the editor can default (version, versionNonce, seed, updated, groupIds, boundElements when empty, roundness, opacity at 100, frameId when null). The editor fills them on open; shipping them can triple the payload for no gain.'),
|
|
2890
|
-
elements: z.array(z.any()).optional().describe('[edit_excalidraw] Excalidraw elements to upsert by id: an element whose id already exists is shallow-merged (a partial {id,x,y} moves it, keeping its other props); a new id is appended. Send only the changed elements — the rest of the scene is preserved. Same lean-fields guidance as excalidraw_data.'),
|
|
2891
|
-
remove: z.array(z.string()).optional().describe('[edit_excalidraw] Element ids to delete from the scene.'),
|
|
2892
|
-
raw: z.boolean().optional().describe('[read] return the frame content as stored bytes (no NNNhash| line-number prefixes) — use when you need to parse the content as JSON (e.g. an Excalidraw scene). Note: edit ops require the hashline anchors, so omit raw when you intend to edit.'),
|
|
2893
|
-
state: z.any().optional().describe('[set_state] App-frame state object (JSON) to persist for a deployed windowType:"app" frame — e.g. {specText:"..."} for the AS/NZS electrical app. The canvas hydrates the app from this on load (the host posts a "hydrate" message with it when the frame mounts), so you can deploy a generic app frame once and drive it with data afterwards. Max 64KB. Frame must be an app frame.'),
|
|
2894
|
-
file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content/base64/googleType.'),
|
|
2895
|
-
base64: z.string().optional().describe('[write] base64-encoded binary content. Mutually exclusive with content/file_path/googleType. Use with content_type when known.'),
|
|
2896
|
-
content_type: z.string().optional().describe('[write + base64] MIME type for base64 binary content, e.g. image/png, application/pdf. Defaults from the path extension or application/octet-stream.'),
|
|
2897
|
-
googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] Create or attach a native Google Workspace frame. Use with title to create new, or url/googleId to attach existing.'),
|
|
2898
|
-
title: z.string().optional().describe('[write + googleType] Title for a new native file. Defaults to filename from path.'),
|
|
2899
|
-
url: z.string().optional().describe('[write + googleType] Existing Google file URL to attach.'),
|
|
2900
|
-
googleId: z.string().optional().describe('[write + googleType] Existing Google file ID to attach. [Sheet/Doc/Slide actions] Native Google file ID to use when not resolving from path/frame.'),
|
|
2901
|
-
officeType: z.enum(['xlsx', 'docx']).optional().describe('[create_office] native Drafted-owned Office format to author: xlsx (spreadsheet) or docx (document). Real OOXML bytes — download is the file itself, no conversion.'),
|
|
2902
|
-
rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional().describe('[create_office xlsx] initial rows for a single-sheet xlsx (2D array of cell values).'),
|
|
2903
|
-
sheets: z.array(z.object({ name: z.string().optional(), rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional() })).optional().describe('[create_office xlsx] multiple sheets: [{ name, rows }].'),
|
|
2904
|
-
paragraphs: z.array(z.object({ html: z.string().optional(), text: z.string().optional(), style: z.string().optional(), align: z.string().optional(), list: z.string().optional() })).optional().describe('[create_office docx] document paragraphs: [{ html?, text?, style?, align?, list? }]. html uses the inline subset <b>,<i>,<u>,<s>,<span style="color:#c00;font-size:12pt;font-family:Arial">. style = Heading1..Heading6 or Title. align = left|center|right|justify. list = bullet|number.'),
|
|
2905
|
-
sheet: z.string().optional().describe('[read_office xlsx] limit the read to one sheet by name.'),
|
|
2906
|
-
maxCells: z.number().optional().describe('[read_office xlsx] cap non-empty cells returned (default 5000).'),
|
|
2907
|
-
ops: z.array(z.object({
|
|
2908
|
-
type: z.string(),
|
|
2909
|
-
sheet: z.string().optional(),
|
|
2910
|
-
ref: z.string().optional(),
|
|
2911
|
-
value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
|
|
2912
|
-
formula: z.string().optional(),
|
|
2913
|
-
cells: z.array(z.object({ ref: z.string(), value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(), formula: z.string().optional(), cellStyle: z.object({ bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), fontSize: z.number().optional(), fontName: z.string().optional(), color: z.string().optional(), bg: z.string().optional(), align: z.string().optional(), valign: z.string().optional(), numFmt: z.string().optional(), border: z.boolean().optional() }).optional() })).optional(),
|
|
2914
|
-
range: z.string().optional(),
|
|
2915
|
-
name: z.string().optional(),
|
|
2916
|
-
from: z.string().optional(),
|
|
2917
|
-
to: z.string().optional(),
|
|
2918
|
-
rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional(),
|
|
2919
|
-
id: z.string().optional(),
|
|
2920
|
-
html: z.string().optional(),
|
|
2921
|
-
text: z.string().optional(),
|
|
2922
|
-
find: z.string().optional(),
|
|
2923
|
-
replace: z.string().optional(),
|
|
2924
|
-
all: z.boolean().optional(),
|
|
2925
|
-
start: z.number().optional(),
|
|
2926
|
-
end: z.number().optional(),
|
|
2927
|
-
style: z.string().optional(),
|
|
2928
|
-
align: z.string().optional(),
|
|
2929
|
-
list: z.string().optional(),
|
|
2930
|
-
header: z.boolean().optional(),
|
|
2931
|
-
col: z.string().optional(),
|
|
2932
|
-
width: z.number().optional(),
|
|
2933
|
-
columns: z.array(z.object({ col: z.string(), width: z.number() })).optional(),
|
|
2934
|
-
cellStyle: z.object({ bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), fontSize: z.number().optional(), fontName: z.string().optional(), color: z.string().optional(), bg: z.string().optional(), align: z.string().optional(), valign: z.string().optional(), numFmt: z.string().optional(), border: z.boolean().optional() }).optional(),
|
|
2935
|
-
after: z.string().optional(),
|
|
2936
|
-
before: z.string().optional(),
|
|
2937
|
-
attrs: z.object({ b: z.boolean().optional(), i: z.boolean().optional(), u: z.boolean().optional(), strike: z.boolean().optional(), color: z.string().optional(), size: z.number().optional(), font: z.string().optional() }).optional(),
|
|
2938
|
-
})).optional().describe('[edit_office] edit ops, resolved against stable addresses (no index drift). xlsx: {type:"setCell",sheet?,ref,value|formula,cellStyle?} | {type:"setCells",sheet?,cells:[{ref,value|formula,cellStyle?}]} | {type:"styleRange",sheet?,range,cellStyle} | {type:"setColumnWidth",sheet?,columns:[{col,width}]} | {type:"clearRange",sheet?,range} | {type:"addSheet",name,rows?} | {type:"renameSheet",from,to} | {type:"deleteSheet",name}. cellStyle:{bold,italic,underline,fontSize,fontName,color,bg,align,valign,numFmt,border}. docx (id = paraId from read_office): {type:"setParagraph",id,html|text,style?,align?,list?} | {type:"format",id,start,end,attrs:{b,i,u,strike,color,size,font}} (run-level char range) | {type:"replaceText",id,find,replace,all?} | {type:"setStyle",id,style?,align?,list?} | {type:"insertParagraph",after|before,html|text,style?,align?,list?} | {type:"insertTable",after|before,rows:[[...]],header?} | {type:"deleteParagraph",id}. align=left|center|right|justify; list=bullet|number.'),
|
|
2939
|
-
format: z.enum(['plain_text', 'markdown']).optional().describe('[write_doc_content|append_doc_content] Source format hint. Currently plain_text and minimal markdown are accepted as text.'),
|
|
2940
|
-
mode: z.enum(['replace', 'append', 'clear']).optional().describe('[Doc/Slide content actions] Optional mode hint for clients; prefer the explicit write/append/clear action names.'),
|
|
2941
|
-
slides: z.array(z.object({
|
|
2942
|
-
title: z.string().optional(),
|
|
2943
|
-
bullets: z.array(z.string()).optional(),
|
|
2944
|
-
speakerNotes: z.string().optional(),
|
|
2945
|
-
layout: z.string().optional(),
|
|
2946
|
-
})).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
|
|
2947
|
-
requests: z.array(z.any()).optional().describe('[update_doc|update_slide] Raw Google Docs/Slides batchUpdate requests for advanced updates only; common Doc/Slide population should use write_doc_content/append_doc_content/write_slide_content/append_slides.'),
|
|
2948
|
-
slideObjectIds: z.array(z.string()).optional().describe('[clear_slides] Optional slide object IDs to delete. Omit to clear all slides.'),
|
|
2949
|
-
range: z.string().optional().describe('[Sheet value actions] A1 range, e.g. Sheet1!A1 or Data!A:Z.'),
|
|
2950
|
-
values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional().describe('[write_sheet_values|append_sheet_rows] 2D array of row values (each cell: string, number, boolean, or null).'),
|
|
2951
|
-
valueInputOption: z.enum(['RAW', 'USER_ENTERED']).optional().describe('[write_sheet_values|append_sheet_rows] Google Sheets value input option. Defaults to USER_ENTERED.'),
|
|
2952
|
-
majorDimension: z.enum(['ROWS', 'COLUMNS']).optional().describe('[Sheet value actions] Major dimension for values. Defaults to ROWS when writing/appending.'),
|
|
2953
|
-
valueRenderOption: z.enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA']).optional().describe('[read_sheet_values] How values should be rendered. Defaults to Google Sheets API default.'),
|
|
2954
|
-
dateTimeRenderOption: z.enum(['SERIAL_NUMBER', 'FORMATTED_STRING']).optional().describe('[read_sheet_values] How dates/times should be rendered.'),
|
|
2955
|
-
insertDataOption: z.enum(['OVERWRITE', 'INSERT_ROWS']).optional().describe('[append_sheet_rows] How new rows are inserted. Defaults to INSERT_ROWS.'),
|
|
2956
|
-
operation: z.enum(['add_sheet', 'rename_sheet']).optional().describe('[update_sheet] Sheet tab operation.'),
|
|
2957
|
-
sheetTitle: z.string().optional().describe('[update_sheet add_sheet] Title for the new sheet tab.'),
|
|
2958
|
-
sheetId: z.number().optional().describe('[update_sheet rename_sheet] Numeric sheet/tab id.'),
|
|
2959
|
-
newTitle: z.string().optional().describe('[update_sheet rename_sheet] New title for the sheet tab.'),
|
|
2960
|
-
autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
|
|
2961
|
-
width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
|
|
2962
|
-
height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
|
|
2963
|
-
color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
|
|
2964
|
-
operations: z.array(z.object({
|
|
2965
|
-
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
2966
|
-
lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash "vix": that is rejected, since the same hash can recur on multiple lines.'),
|
|
2967
|
-
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
2968
|
-
})).optional().describe('[edit] hashline edit operations'),
|
|
2969
|
-
from: z.string().optional().describe('[mv] source path: /{layer}/{lane}/{filename} for a frame, /{layer}/{lane} for a lane, /{layer} for a whole layer (lane/layer require toProjectId)'),
|
|
2970
|
-
to: z.string().optional().describe('[mv] destination path /{layer}/{lane}/{filename} — the layer segment is always required and explicit'),
|
|
2971
|
-
toProjectId: z.string().optional().describe('[mv] move ACROSS projects: destination project UUID/slug/name. Same-org only. Frame ids and version history follow; frame-scoped shares, public links, and lane share tokens are NOT carried into the destination project. Omit for the classic in-project rename/move.'),
|
|
2972
|
-
dryRun: z.boolean().optional().describe('[mv] preview the move without applying it; returns the resolved frame and current path so you can confirm before retrying with dryRun=false.'),
|
|
2973
|
-
anchored: z.boolean().optional().describe('[anchor] true to anchor, false to unanchor. Anchored frames MUST be read before writing/editing in the same layer.'),
|
|
2974
|
-
query: z.string().optional().describe('[search] term to match against frame names'),
|
|
2975
|
-
limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
|
|
2976
|
-
versionId: z.string().optional().describe('[read_version|restore_version] version id'),
|
|
2977
|
-
reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
|
|
2978
|
-
}, async (args) => {
|
|
2979
|
-
try {
|
|
2980
|
-
const { action } = args;
|
|
2981
|
-
// G1: don't edit frame content before the session has searched the wiki.
|
|
2982
|
-
// When blocking, auto-inject the (bounded) wiki index so the search lands.
|
|
2983
|
-
if (G1_MUTATING_FRAME_ACTIONS.has(action)) {
|
|
2984
|
-
const gs = getSessionState().gates;
|
|
2985
|
-
if (!gs.wikiSearched) {
|
|
2986
|
-
let wikiIndex = '';
|
|
2987
|
-
try { const tree = await api('GET', '/api/wiki/tree'); wikiIndex = formatWikiIndex(tree?.pages || []); } catch { /* index best-effort */ }
|
|
2988
|
-
return err(new Error(g1Block(gs, wikiIndex)));
|
|
2989
|
-
}
|
|
2990
|
-
}
|
|
2991
|
-
// Echo project context on every mutation so the agent sees where the
|
|
2992
|
-
// write landed before it can develop a wrong assumption.
|
|
2993
|
-
const projectCtx = getCurrentProjectContext();
|
|
2994
|
-
const withProject = (result) => ({ ...result, project: projectCtx });
|
|
2995
|
-
const workspaceBody = (kind) => {
|
|
2996
|
-
const { path, googleId } = args;
|
|
2997
|
-
const body = {};
|
|
2998
|
-
const frameUrlMatch = path?.match(/\/f\/([a-f0-9-]{36})/);
|
|
2999
|
-
const uuidMatch = path?.match(/^[a-f0-9-]{36}$/);
|
|
3000
|
-
if (googleId) {
|
|
3001
|
-
if (kind === 'sheet') body.spreadsheetId = googleId;
|
|
3002
|
-
else if (kind === 'doc') body.documentId = googleId;
|
|
3003
|
-
else body.presentationId = googleId;
|
|
3004
|
-
} else if (frameUrlMatch?.[1] || uuidMatch) {
|
|
3005
|
-
body.frameId = frameUrlMatch?.[1] || path;
|
|
3006
|
-
} else if (path) {
|
|
3007
|
-
body.path = path;
|
|
3008
|
-
body.projectId = getState().projectId;
|
|
3009
|
-
} else {
|
|
3010
|
-
const label = kind === 'sheet' ? 'Google Sheet' : kind === 'doc' ? 'Google Doc' : 'Google Slide';
|
|
3011
|
-
throw new Error(`Provide path to a ${label} frame, frame ID/URL, or googleId/native file ID`);
|
|
3012
|
-
}
|
|
3013
|
-
return body;
|
|
3014
|
-
};
|
|
3015
|
-
// Resolve a frame path / URL / UUID to a frame id for the native Office routes.
|
|
3016
|
-
const resolveOfficeFrameId = async (path) => {
|
|
3017
|
-
if (!path) throw new Error('Provide path to an office frame, frame URL, or frame ID');
|
|
3018
|
-
const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
|
|
3019
|
-
const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
|
|
3020
|
-
if (frameUrlMatch?.[1] || uuidMatch) return frameUrlMatch?.[1] || path;
|
|
3021
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
3022
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
|
|
3023
|
-
const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
|
|
3024
|
-
const id = frame?.id || frame?.frame?.id;
|
|
3025
|
-
if (!id) throw new Error('Office frame not found for path');
|
|
3026
|
-
return id;
|
|
3027
|
-
};
|
|
3028
|
-
switch (action) {
|
|
3029
|
-
case 'read': {
|
|
3030
|
-
const { path, lines, raw } = args;
|
|
3031
|
-
if (!path) throw new Error('path required for action=read');
|
|
3032
|
-
const qs = [];
|
|
3033
|
-
if (lines) qs.push(`lines=${encodeURIComponent(lines)}`);
|
|
3034
|
-
if (raw) qs.push('raw=1');
|
|
3035
|
-
const query = qs.length ? `?${qs.join('&')}` : '';
|
|
3036
|
-
const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
|
|
3037
|
-
const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
|
|
3038
|
-
const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
|
|
3039
|
-
let result;
|
|
3040
|
-
if (frameId) {
|
|
3041
|
-
result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
|
|
3042
|
-
} else {
|
|
3043
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
3044
|
-
// 2 segments = a layer-root frame (empty lane, e.g. /designs/AGENTS.md);
|
|
3045
|
-
// 3 = /{layer}/{lane}/{filename}. Both resolve server-side.
|
|
3046
|
-
if (parts.length !== 2 && parts.length !== 3) throw new Error('Path must be /{layer}/{filename}, /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
|
|
3047
|
-
result = await api('GET', `/api/fs/${parts.map(encodeURIComponent).join('/')}${query}`);
|
|
3048
|
-
}
|
|
3049
|
-
// Surface content as the visible text. Some Claude clients prefer
|
|
3050
|
-
// structuredContent over text when both are present and structured looks
|
|
3051
|
-
// "complete" — so include content in BOTH places to ensure the agent
|
|
3052
|
-
// can always see the frame's actual content, not just metadata.
|
|
3053
|
-
const structured = frameStructuredContent(result, projectCtx);
|
|
3054
|
-
structured.content = result.content ?? '';
|
|
3055
|
-
structured.size = result.size;
|
|
3056
|
-
structured.totalLines = result.totalLines;
|
|
3057
|
-
const visibleText = JSON.stringify({
|
|
3058
|
-
path: result.path,
|
|
3059
|
-
contentType: result.contentType,
|
|
3060
|
-
size: result.size,
|
|
3061
|
-
totalLines: result.totalLines,
|
|
3062
|
-
content: result.content ?? '',
|
|
3063
|
-
}, null, 2);
|
|
3064
|
-
return ok(visibleText, {
|
|
3065
|
-
structuredContent: structured,
|
|
3066
|
-
_meta: { frameHtml: result.content },
|
|
3067
|
-
});
|
|
3068
|
-
}
|
|
3069
|
-
case 'set_state':
|
|
3070
|
-
case 'get_state': {
|
|
3071
|
-
// Read/write the persisted appState of a deployed windowType:'app' frame
|
|
3072
|
-
// (designs.metadata.appState via /api/file/:id/state). The canvas hydrates
|
|
3073
|
-
// the app from this on load — so a generic app frame can be driven with
|
|
3074
|
-
// data after deploy (e.g. push {specText} to the AS/NZS electrical app).
|
|
3075
|
-
const { path } = args;
|
|
3076
|
-
if (!path) throw new Error(`path required for action=${action}`);
|
|
3077
|
-
const urlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
|
|
3078
|
-
const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
|
|
3079
|
-
let frameId = urlMatch?.[1] || (uuidMatch ? path : null);
|
|
3080
|
-
if (!frameId) {
|
|
3081
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
3082
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
|
|
3083
|
-
const f = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
|
|
3084
|
-
frameId = f.id;
|
|
3085
|
-
if (!frameId) throw new Error(`Could not resolve a frame id for ${path}`);
|
|
3086
|
-
}
|
|
3087
|
-
if (action === 'get_state') {
|
|
3088
|
-
const r = await api('GET', `/api/file/${frameId}/state`);
|
|
3089
|
-
return ok(JSON.stringify({ frameId, state: r.state ?? null }, null, 2));
|
|
3090
|
-
}
|
|
3091
|
-
if (args.state === undefined) throw new Error('state required for action=set_state — the app-frame state object to persist; the app hydrates from it on load');
|
|
3092
|
-
await api('POST', `/api/file/${frameId}/state`, { state: args.state });
|
|
3093
|
-
return ok(`App state persisted to frame ${frameId}. The app hydrates from it on next load — open or refresh the frame to see it render with the new state.`);
|
|
3094
|
-
}
|
|
3095
|
-
case 'get_sheet':
|
|
3096
|
-
case 'read_sheet_values':
|
|
3097
|
-
case 'write_sheet_values':
|
|
3098
|
-
case 'append_sheet_rows':
|
|
3099
|
-
case 'clear_sheet_range':
|
|
3100
|
-
case 'update_sheet': {
|
|
3101
|
-
const { path, googleId, range, values, valueInputOption, majorDimension, valueRenderOption, dateTimeRenderOption, insertDataOption, operation, sheetTitle, sheetId, newTitle } = args;
|
|
3102
|
-
const body = workspaceBody('sheet');
|
|
3103
|
-
if (range) body.range = range;
|
|
3104
|
-
if (majorDimension) body.majorDimension = majorDimension;
|
|
3105
|
-
if (action === 'write_sheet_values' || action === 'append_sheet_rows') {
|
|
3106
|
-
if (!values || !Array.isArray(values)) throw new Error(`values required for action=${action}`);
|
|
3107
|
-
body.values = values;
|
|
3108
|
-
body.valueInputOption = valueInputOption || 'USER_ENTERED';
|
|
3109
|
-
body.majorDimension = majorDimension || 'ROWS';
|
|
3110
|
-
}
|
|
3111
|
-
if (action === 'read_sheet_values') {
|
|
3112
|
-
if (valueRenderOption) body.valueRenderOption = valueRenderOption;
|
|
3113
|
-
if (dateTimeRenderOption) body.dateTimeRenderOption = dateTimeRenderOption;
|
|
3114
|
-
}
|
|
3115
|
-
if (action === 'append_sheet_rows') body.insertDataOption = insertDataOption || 'INSERT_ROWS';
|
|
3116
|
-
if (action === 'update_sheet') {
|
|
3117
|
-
body.operation = operation;
|
|
3118
|
-
if (sheetTitle) body.sheetTitle = sheetTitle;
|
|
3119
|
-
if (sheetId != null) body.sheetId = sheetId;
|
|
3120
|
-
if (newTitle) body.newTitle = newTitle;
|
|
3121
|
-
}
|
|
3122
|
-
const endpoint = action === 'get_sheet'
|
|
3123
|
-
? '/api/google/workspace/sheet'
|
|
3124
|
-
: action === 'read_sheet_values'
|
|
3125
|
-
? '/api/google/workspace/sheet-values/read'
|
|
3126
|
-
: action === 'write_sheet_values'
|
|
3127
|
-
? '/api/google/workspace/sheet-values'
|
|
3128
|
-
: action === 'append_sheet_rows'
|
|
3129
|
-
? '/api/google/workspace/sheet-values/append'
|
|
3130
|
-
: action === 'clear_sheet_range'
|
|
3131
|
-
? '/api/google/workspace/sheet-values/clear'
|
|
3132
|
-
: '/api/google/workspace/sheet-update';
|
|
3133
|
-
const result = await api('POST', endpoint, body);
|
|
3134
|
-
return ok(withProject(result));
|
|
3135
|
-
}
|
|
3136
|
-
case 'get_doc':
|
|
3137
|
-
case 'read_doc_content':
|
|
3138
|
-
case 'write_doc_content':
|
|
3139
|
-
case 'append_doc_content':
|
|
3140
|
-
case 'clear_doc_content':
|
|
3141
|
-
case 'update_doc': {
|
|
3142
|
-
const { content, format, mode, requests } = args;
|
|
3143
|
-
const body = workspaceBody('doc');
|
|
3144
|
-
if (format) body.format = format;
|
|
3145
|
-
if (mode) body.mode = mode;
|
|
3146
|
-
if (action === 'write_doc_content' || action === 'append_doc_content') {
|
|
3147
|
-
if (typeof content !== 'string') throw new Error(`content string required for action=${action}`);
|
|
3148
|
-
body.content = content;
|
|
3149
|
-
}
|
|
3150
|
-
if (action === 'update_doc') {
|
|
3151
|
-
if (!Array.isArray(requests)) throw new Error('requests array required for action=update_doc');
|
|
3152
|
-
body.requests = requests;
|
|
3153
|
-
}
|
|
3154
|
-
const endpoint = action === 'get_doc'
|
|
3155
|
-
? '/api/google/workspace/doc'
|
|
3156
|
-
: action === 'read_doc_content'
|
|
3157
|
-
? '/api/google/workspace/doc-content/read'
|
|
3158
|
-
: action === 'write_doc_content'
|
|
3159
|
-
? '/api/google/workspace/doc-content'
|
|
3160
|
-
: action === 'append_doc_content'
|
|
3161
|
-
? '/api/google/workspace/doc-content/append'
|
|
3162
|
-
: action === 'clear_doc_content'
|
|
3163
|
-
? '/api/google/workspace/doc-content/clear'
|
|
3164
|
-
: '/api/google/workspace/doc-update';
|
|
3165
|
-
const result = await api('POST', endpoint, body);
|
|
3166
|
-
return ok(withProject(result));
|
|
3167
|
-
}
|
|
3168
|
-
case 'get_slide':
|
|
3169
|
-
case 'read_slide_content':
|
|
3170
|
-
case 'write_slide_content':
|
|
3171
|
-
case 'append_slides':
|
|
3172
|
-
case 'clear_slides':
|
|
3173
|
-
case 'update_slide': {
|
|
3174
|
-
const { slides, requests, slideObjectIds, mode } = args;
|
|
3175
|
-
const body = workspaceBody('slide');
|
|
3176
|
-
if (mode) body.mode = mode;
|
|
3177
|
-
if (action === 'write_slide_content' || action === 'append_slides') {
|
|
3178
|
-
if (!Array.isArray(slides)) throw new Error(`slides array required for action=${action}`);
|
|
3179
|
-
body.slides = slides;
|
|
3180
|
-
}
|
|
3181
|
-
if (action === 'clear_slides' && Array.isArray(slideObjectIds)) body.slideObjectIds = slideObjectIds;
|
|
3182
|
-
if (action === 'update_slide') {
|
|
3183
|
-
if (!Array.isArray(requests)) throw new Error('requests array required for action=update_slide');
|
|
3184
|
-
body.requests = requests;
|
|
3185
|
-
}
|
|
3186
|
-
const endpoint = action === 'get_slide'
|
|
3187
|
-
? '/api/google/workspace/slide'
|
|
3188
|
-
: action === 'read_slide_content'
|
|
3189
|
-
? '/api/google/workspace/slide-content/read'
|
|
3190
|
-
: action === 'write_slide_content'
|
|
3191
|
-
? '/api/google/workspace/slide-content'
|
|
3192
|
-
: action === 'append_slides'
|
|
3193
|
-
? '/api/google/workspace/slides/append'
|
|
3194
|
-
: action === 'clear_slides'
|
|
3195
|
-
? '/api/google/workspace/slides/clear'
|
|
3196
|
-
: '/api/google/workspace/slide-update';
|
|
3197
|
-
const result = await api('POST', endpoint, body);
|
|
3198
|
-
return ok(withProject(result));
|
|
3199
|
-
}
|
|
3200
|
-
case 'create_office': {
|
|
3201
|
-
// Author a native Drafted-owned Office file (real bytes; download is the
|
|
3202
|
-
// file itself). Populate via edit_office; read structure via read_office.
|
|
3203
|
-
const { path, title, officeType = 'xlsx', rows, sheets, paragraphs } = args;
|
|
3204
|
-
let layer = 'copy';
|
|
3205
|
-
let lane = 'default';
|
|
3206
|
-
let label = title;
|
|
3207
|
-
if (path) {
|
|
3208
|
-
const p = path.replace(/^\/+/, '').split('/');
|
|
3209
|
-
if (p.length === 3) { layer = p[0]; lane = p[1]; label = p[2]; }
|
|
3210
|
-
}
|
|
3211
|
-
if (!label) throw new Error('Provide title (filename) or a /{layer}/{lane}/{filename} path for the new office file');
|
|
3212
|
-
return ok(await api('POST', '/api/office/create', { projectId: getState().projectId, layer, lane, label, format: officeType, rows, sheets, paragraphs }));
|
|
3213
|
-
}
|
|
3214
|
-
case 'read_office': {
|
|
3215
|
-
const id = await resolveOfficeFrameId(args.path);
|
|
3216
|
-
const q = [];
|
|
3217
|
-
if (args.sheet) q.push('sheet=' + encodeURIComponent(args.sheet));
|
|
3218
|
-
if (args.maxCells) q.push('maxCells=' + Number(args.maxCells));
|
|
3219
|
-
return ok(await api('GET', `/api/office/${id}${q.length ? '?' + q.join('&') : ''}`));
|
|
3220
|
-
}
|
|
3221
|
-
case 'edit_office': {
|
|
3222
|
-
if (!Array.isArray(args.ops) || !args.ops.length) throw new Error('ops (non-empty array) required for action=edit_office');
|
|
3223
|
-
const id = await resolveOfficeFrameId(args.path);
|
|
3224
|
-
return ok(await api('POST', `/api/office/${id}/edit`, { ops: args.ops }));
|
|
3225
|
-
}
|
|
3226
|
-
case 'write': {
|
|
3227
|
-
const { path, content, file_path, base64, content_type, autoSize, width, height, color, googleType, title, url, googleId } = args;
|
|
3228
|
-
if (!path) throw new Error('path required for action=write');
|
|
3229
|
-
const writeSources = [content != null, !!file_path, base64 != null, !!googleType].filter(Boolean).length;
|
|
3230
|
-
if (writeSources > 1) throw new Error('Provide only one of content, file_path, base64, or googleType');
|
|
3231
|
-
if (writeSources === 0) throw new Error('Provide content, file_path, base64, or googleType');
|
|
3232
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
3233
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
3234
|
-
if (googleType) {
|
|
3235
|
-
const projectId = getState().projectId;
|
|
3236
|
-
if (!projectId) throw new Error('Open a project first with project(action="open") before creating Google Workspace frames');
|
|
3237
|
-
const label = basename(parts[2], extname(parts[2])) || parts[2];
|
|
3238
|
-
const body = {
|
|
3239
|
-
projectId,
|
|
3240
|
-
layer: parts[0],
|
|
3241
|
-
lane: parts[1],
|
|
3242
|
-
label,
|
|
3243
|
-
type: googleType,
|
|
3244
|
-
width,
|
|
3245
|
-
height,
|
|
3246
|
-
};
|
|
3247
|
-
const result = url || googleId
|
|
3248
|
-
? await api('POST', '/api/google/workspace/frame', { ...body, url, googleId })
|
|
3249
|
-
: await api('POST', '/api/google/workspace/create-frame', { ...body, title: title || label });
|
|
3250
|
-
return ok(withProject(withFrameBreadcrumb({
|
|
3251
|
-
...result,
|
|
3252
|
-
path,
|
|
3253
|
-
label,
|
|
3254
|
-
contentType: 'text/html',
|
|
3255
|
-
sourceType: googleType,
|
|
3256
|
-
}, { hint: true })), {
|
|
3257
|
-
structuredContent: frameStructuredContent({ ...result, path, label, contentType: 'text/html' }, projectCtx),
|
|
3258
|
-
});
|
|
3259
|
-
}
|
|
3260
|
-
let body;
|
|
3261
|
-
if (file_path) {
|
|
3262
|
-
const resolved = resolve(file_path);
|
|
3263
|
-
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
3264
|
-
const ext = extname(resolved).toLowerCase();
|
|
3265
|
-
const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
|
|
3266
|
-
if (TEXT_EXTS.includes(ext)) {
|
|
3267
|
-
body = { content: readFileSync(resolved, 'utf8') };
|
|
3268
|
-
if (autoSize) body.autoSize = true;
|
|
3269
|
-
} else {
|
|
3270
|
-
const buffer = readFileSync(resolved);
|
|
3271
|
-
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.m4v': 'video/x-m4v' };
|
|
3272
|
-
body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
|
|
3273
|
-
}
|
|
3274
|
-
} else if (base64 != null) {
|
|
3275
|
-
body = { base64, contentType: content_type || mimeFromExt(extname(parts[2])) };
|
|
3276
|
-
} else {
|
|
3277
|
-
body = { content };
|
|
3278
|
-
if (autoSize) body.autoSize = true;
|
|
3279
|
-
}
|
|
3280
|
-
if (width) body.width = width;
|
|
3281
|
-
if (height) body.height = height;
|
|
3282
|
-
if (color) body.color = color;
|
|
3283
|
-
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
|
|
3284
|
-
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
3285
|
-
structuredContent: frameStructuredContent(result, projectCtx),
|
|
3286
|
-
_meta: body.content ? { frameHtml: body.content } : undefined,
|
|
3287
|
-
});
|
|
3288
|
-
}
|
|
3289
|
-
case 'write_excalidraw': {
|
|
3290
|
-
const { path, excalidraw_data, width, height, color } = args;
|
|
3291
|
-
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
3292
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
3293
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
3294
|
-
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
3295
|
-
const scene = excalidraw_data ?? emptyExcalidrawScene();
|
|
3296
|
-
const body = { content: stringifyExcalidrawScene(scene) };
|
|
3297
|
-
if (width) body.width = width;
|
|
3298
|
-
if (height) body.height = height;
|
|
3299
|
-
if (color) body.color = color;
|
|
3300
|
-
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
|
|
3301
|
-
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
3302
|
-
structuredContent: frameStructuredContent(result, projectCtx),
|
|
3303
|
-
_meta: { frameHtml: body.content },
|
|
3304
|
-
});
|
|
3305
|
-
}
|
|
3306
|
-
case 'edit_excalidraw': {
|
|
3307
|
-
const { path, elements, remove } = args;
|
|
3308
|
-
if (!path) throw new Error('path required for action=edit_excalidraw');
|
|
3309
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
3310
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
3311
|
-
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
3312
|
-
if (!Array.isArray(elements) && !Array.isArray(remove)) {
|
|
3313
|
-
throw new Error('Provide elements[] to add/update and/or remove[] element ids');
|
|
3314
|
-
}
|
|
3315
|
-
const result = await api('POST', '/api/fs/edit-excalidraw', { path: `/${parts[0]}/${parts[1]}/${filename}`, elements, remove });
|
|
3316
|
-
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
3317
|
-
structuredContent: frameStructuredContent(result, projectCtx),
|
|
3318
|
-
});
|
|
3319
|
-
}
|
|
3320
|
-
case 'edit': {
|
|
3321
|
-
const { path, operations } = args;
|
|
3322
|
-
if (!path) throw new Error('path required for action=edit');
|
|
3323
|
-
if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
|
|
3324
|
-
const result = await api('POST', '/api/fs/edit', { path, operations });
|
|
3325
|
-
return ok(withProject(result), {
|
|
3326
|
-
structuredContent: frameStructuredContent(result, projectCtx),
|
|
3327
|
-
_meta: result.content ? { frameHtml: result.content } : undefined,
|
|
3328
|
-
});
|
|
3329
|
-
}
|
|
3330
|
-
case 'mv': {
|
|
3331
|
-
const { from, to, toProjectId, dryRun = false } = args;
|
|
3332
|
-
if (!from || !to) throw new Error('from and to required for action=mv');
|
|
3333
|
-
if (dryRun) {
|
|
3334
|
-
// Resolve current frame at `from`. Don't apply the rename, just
|
|
3335
|
-
// confirm the source exists and report what `to` will become.
|
|
3336
|
-
const current = await api('GET', `/api/fs?path=${encodeURIComponent(from)}`);
|
|
3337
|
-
return ok(withProject({
|
|
3338
|
-
dryRun: true,
|
|
3339
|
-
from,
|
|
3340
|
-
to,
|
|
3341
|
-
toProjectId: toProjectId || undefined,
|
|
3342
|
-
currentFrame: current?.frame || current,
|
|
3343
|
-
note: 'No changes applied. Re-call with dryRun=false (or omit) to perform the move.',
|
|
3344
|
-
}));
|
|
3345
|
-
}
|
|
3346
|
-
return ok(withProject(await api('POST', '/api/fs/mv', { from, to, ...(toProjectId ? { toProjectId } : {}) })));
|
|
3347
|
-
}
|
|
3348
|
-
case 'anchor': {
|
|
3349
|
-
const { path, anchored } = args;
|
|
3350
|
-
if (!path) throw new Error('path required for action=anchor');
|
|
3351
|
-
if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
|
|
3352
|
-
// Cap (G5): anchoring adds the frame's body to the project's required-reading
|
|
3353
|
-
// set — reject if it would push the project past the per-project budget.
|
|
3354
|
-
if (anchored === true) {
|
|
3355
|
-
const used = await getProjectPrimingSize(getState().projectId);
|
|
3356
|
-
const addSize = await getFrameContentSize(path);
|
|
3357
|
-
if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this anchored frame')));
|
|
3358
|
-
}
|
|
3359
|
-
return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
|
|
3360
|
-
}
|
|
3361
|
-
|
|
3362
|
-
case 'versions': {
|
|
3363
|
-
const { path } = args;
|
|
3364
|
-
if (!path) throw new Error('path required for action=versions');
|
|
3365
|
-
const result = await api('GET', `/api/fs/versions?path=${encodeURIComponent(path)}`);
|
|
3366
|
-
return ok(withProject(result));
|
|
3367
|
-
}
|
|
3368
|
-
case 'read_version': {
|
|
3369
|
-
const { versionId } = args;
|
|
3370
|
-
if (!versionId) throw new Error('versionId required for action=read_version');
|
|
3371
|
-
const result = await api('GET', `/api/fs/versions/${versionId}`);
|
|
3372
|
-
return ok(withProject(result));
|
|
3373
|
-
}
|
|
3374
|
-
case 'restore_version': {
|
|
3375
|
-
const { versionId, reason } = args;
|
|
3376
|
-
if (!versionId) throw new Error('versionId required for action=restore_version');
|
|
3377
|
-
const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
|
|
3378
|
-
return ok(withProject(result));
|
|
3379
|
-
}
|
|
3380
|
-
case 'search': {
|
|
3381
|
-
const { query, projectId, limit = 50 } = args;
|
|
3382
|
-
if (!query) throw new Error('query required for action=search');
|
|
3383
|
-
const params = new URLSearchParams({ q: query });
|
|
3384
|
-
// projectId may be a slug/name (the shared override param) — the tool() seam has
|
|
3385
|
-
// already resolved it into state, so take the resolved UUID from there.
|
|
3386
|
-
if (projectId) params.set('projectId', getState().projectId || projectId);
|
|
3387
|
-
await ensureSession();
|
|
3388
|
-
const url = `${getServerUrl()}/api/search?${params.toString()}`;
|
|
3389
|
-
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
3390
|
-
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
|
|
3391
|
-
const results = await res.json();
|
|
3392
|
-
const cap = Math.min(Math.max(1, limit || 50), 200);
|
|
3393
|
-
const slice = results.slice(0, cap);
|
|
3394
|
-
return ok({
|
|
3395
|
-
results: slice.map(r => ({
|
|
3396
|
-
id: r.id,
|
|
3397
|
-
path: `/${r.layer}/${r.lane}/${r.label}`,
|
|
3398
|
-
project: r.projectName,
|
|
3399
|
-
projectId: r.projectId,
|
|
3400
|
-
frameUrl: semanticFrameUrl(r) || `${getServerUrl()}/f/${r.id}`,
|
|
3401
|
-
contentType: r.contentType,
|
|
3402
|
-
updatedAt: r.updatedAt,
|
|
3403
|
-
})),
|
|
3404
|
-
totalAvailable: results.length,
|
|
3405
|
-
truncated: results.length > cap,
|
|
3406
|
-
});
|
|
3407
|
-
}
|
|
3408
|
-
default:
|
|
3409
|
-
throw new Error(`Unknown frame action: ${action}`);
|
|
3410
|
-
}
|
|
3411
|
-
} catch (error) { return err(error); }
|
|
3412
|
-
});
|
|
3413
|
-
|
|
3414
|
-
tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="open") to see layers, workflow, and confirm you\'re in the right project.', {
|
|
3415
|
-
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3416
|
-
path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID); layer entries include layerUrl and lane entries laneUrl — relay those to the user instead of the project URL when your work was scoped to one layer or lane.'),
|
|
3417
|
-
recursive: z.boolean().optional().describe('List contents of subdirectories. When true, forces summary mode (metadata only, no full content) to keep results under the 25k token cap.'),
|
|
3418
|
-
summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
|
|
3419
|
-
pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
|
|
3420
|
-
head: z.number().optional().describe('Include the first N lines of EACH frame inline (max 50). Survey a whole lane in one call instead of one frame(action="read") per frame — read only the frames the heads show you actually need.'),
|
|
3421
|
-
limit: z.number().optional().default(500).describe('Max entries to return (default 500, max 2000). Use pattern or path to scope further.'),
|
|
3422
|
-
}, async ({ path, recursive, summary, pattern, head, limit }) => {
|
|
3423
|
-
try {
|
|
3424
|
-
const params = new URLSearchParams();
|
|
3425
|
-
params.set('path', path);
|
|
3426
|
-
// At layer depth (/designs, /wireframes, etc.) auto-recurse so frames in sublanes
|
|
3427
|
-
// are always visible — without this, agents only see root frames and miss any frame
|
|
3428
|
-
// whose lane != "". Recursive walks force summary mode to keep payloads small.
|
|
3429
|
-
const cleanPath = (path || '/').replace(/^\/+|\/+$/g, '');
|
|
3430
|
-
const pathDepth = cleanPath ? cleanPath.split('/').length : 0;
|
|
3431
|
-
if (recursive || pathDepth === 1) {
|
|
3432
|
-
params.set('recursive', 'true');
|
|
3433
|
-
params.set('summary', 'true');
|
|
3434
|
-
} else if (summary) {
|
|
3435
|
-
params.set('summary', 'true');
|
|
3436
|
-
}
|
|
3437
|
-
if (head) params.set('head', String(head));
|
|
3438
|
-
if (pattern) params.set('pattern', pattern);
|
|
3439
|
-
const result = await api('GET', `/api/fs/?${params.toString()}`);
|
|
3440
|
-
|
|
3441
|
-
// Cap entries client-side as a safety net
|
|
3442
|
-
const cap = Math.min(Math.max(1, limit || 500), 2000);
|
|
3443
|
-
if (Array.isArray(result?.entries) && result.entries.length > cap) {
|
|
3444
|
-
result.totalAvailable = result.entries.length;
|
|
3445
|
-
result.truncated = true;
|
|
3446
|
-
result.entries = result.entries.slice(0, cap);
|
|
3447
|
-
}
|
|
3448
|
-
|
|
3449
|
-
// ChatGPT Apps SDK: canvas-overview widget renders byLayer.
|
|
3450
|
-
// /api/fs/ entries are { path, type: 'layer'|'lane'|'frame', id, title, size,
|
|
3451
|
-
// updatedAt, color, frameUrl } — there are no layer/lane/name/label fields, so
|
|
3452
|
-
// derive them from the path. Skip non-frame entries (lanes/layers are structural
|
|
3453
|
-
// and would otherwise serialize as empty objects under "unsorted").
|
|
3454
|
-
const entries = Array.isArray(result?.entries) ? result.entries : [];
|
|
3455
|
-
const byLayer = {};
|
|
3456
|
-
for (const e of entries) {
|
|
3457
|
-
if (e.type && e.type !== 'frame') continue;
|
|
3458
|
-
const segs = String(e.path || '').replace(/^\/+/, '').split('/').filter(Boolean);
|
|
3459
|
-
if (!segs.length) continue;
|
|
3460
|
-
const layer = segs[0];
|
|
3461
|
-
const filename = segs[segs.length - 1];
|
|
3462
|
-
const lane = segs.length >= 3 ? segs.slice(1, -1).join('/') : undefined;
|
|
3463
|
-
(byLayer[layer] ??= []).push({
|
|
3464
|
-
label: e.title || filename,
|
|
3465
|
-
filename,
|
|
3466
|
-
title: e.title,
|
|
3467
|
-
layer,
|
|
3468
|
-
lane,
|
|
3469
|
-
id: e.id,
|
|
3470
|
-
frameUrl: e.frameUrl || (e.id ? `${getServerUrl()}/f/${e.id}` : undefined),
|
|
3471
|
-
});
|
|
3472
|
-
}
|
|
3473
|
-
const projectId = getState().projectId;
|
|
3474
|
-
const structuredContent = {
|
|
3475
|
-
project: result?.project || result?.projectName,
|
|
3476
|
-
canvasUrl: result?.project?.slug
|
|
3477
|
-
? (result.project.orgSlug
|
|
3478
|
-
? `${getServerUrl()}/o/${encodeURIComponent(result.project.orgSlug)}/${encodeURIComponent(result.project.slug)}`
|
|
3479
|
-
: `${getServerUrl()}/project/${encodeURIComponent(result.project.slug)}`)
|
|
3480
|
-
: undefined,
|
|
3481
|
-
byLayer,
|
|
3482
|
-
truncated: result?.truncated || false,
|
|
3483
|
-
totalAvailable: result?.totalAvailable,
|
|
3484
|
-
};
|
|
3485
|
-
return ok(result, { structuredContent });
|
|
3486
|
-
} catch (error) { return err(error); }
|
|
3487
|
-
});
|
|
3488
|
-
|
|
3489
|
-
tool('rm', 'Delete a frame or lane. Defaults to the ACTIVE PROJECT; pass projectId to delete in another project, or frameId to delete a frame by UUID from anywhere (no project binding needed — the frame\'s own org/project is authoritative). Response includes "project" so you see where the deletion landed.', {
|
|
3490
|
-
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3491
|
-
path: z.string().optional().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane). Mutually exclusive with frameId.'),
|
|
3492
|
-
frameId: z.string().optional().describe('Frame UUID to delete, resolved independently of the active project — the one-call cleanup for a frame that landed in the wrong place. Mutually exclusive with path.'),
|
|
3493
|
-
}, async ({ path, frameId }) => {
|
|
3494
|
-
try {
|
|
3495
|
-
if (frameId && path) throw new Error('Pass either path or frameId, not both.');
|
|
3496
|
-
if (!frameId && !path) throw new Error('rm needs a path or a frameId.');
|
|
3497
|
-
// A frame UUID self-derives its org and project server-side, so this works from an
|
|
3498
|
-
// unbound session and across projects — no open/re-open dance to remove a stray frame.
|
|
3499
|
-
if (frameId) return ok(await api('DELETE', `/api/designs/${encodeURIComponent(frameId)}`));
|
|
3500
|
-
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
3501
|
-
const result = await api('DELETE', `/api/fs/${clean}`);
|
|
3502
|
-
return ok({ ...result, project: getCurrentProjectContext() });
|
|
3503
|
-
} catch (error) { return err(error); }
|
|
3504
|
-
});
|
|
3505
|
-
|
|
3506
|
-
// batch tool temporarily disabled — keep code intact for re-enablement later.
|
|
3507
|
-
/*
|
|
3508
|
-
tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "project" field. Use project(action="open") first if needed.', {
|
|
3509
|
-
operations: z.array(z.object({
|
|
3510
|
-
tool: z.enum(['write', 'rm', 'mv', 'edit', 'upload_asset']).describe('Tool to execute'),
|
|
3511
|
-
path: z.string().optional().describe('Path (for write, rm, edit)'),
|
|
3512
|
-
content: z.string().optional().describe(`Content (for write).${isRemote ? '' : ' Mutually exclusive with file_path.'}`),
|
|
3513
|
-
// file_path is local-only — omitted on remote transports (web MCP).
|
|
3514
|
-
...(isRemote ? {} : { file_path: z.string().optional().describe('Absolute path to a local file to upload (for write, upload_asset). Mutually exclusive with content.') }),
|
|
3515
|
-
color: z.string().optional().describe('CSS color for frame border (for write)'),
|
|
3516
|
-
from: z.string().optional().describe('Source path (for mv)'),
|
|
3517
|
-
to: z.string().optional().describe('Destination path (for mv)'),
|
|
3518
|
-
operations: z.array(z.object({
|
|
3519
|
-
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']),
|
|
3520
|
-
lineHash: z.string().describe('The full line anchor from read output — line number + 3-char hash, e.g. "182vix". NOT the bare hash.'),
|
|
3521
|
-
newContent: z.string().optional(),
|
|
3522
|
-
})).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vix".'),
|
|
3523
|
-
asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
|
|
3524
|
-
content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
|
|
3525
|
-
frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
|
|
3526
|
-
})).describe('ALWAYS use batch instead of multiple individual tool calls. Applies the same change to many files, writes multiple frames, or combines writes+edits+deletes. One canvas refresh instead of many. Example: editing 5 wireframes to remove a section = one batch with 5 edit operations.'),
|
|
3527
|
-
}, async ({ operations }) => {
|
|
3528
|
-
try {
|
|
3529
|
-
// Collect all layers involved in the batch
|
|
3530
|
-
const layers = new Set();
|
|
3531
|
-
for (const op of operations) {
|
|
3532
|
-
if (op.path) layers.add(parseLayer(op.path));
|
|
3533
|
-
if (op.from) layers.add(parseLayer(op.from));
|
|
3534
|
-
if (op.to) layers.add(parseLayer(op.to));
|
|
3535
|
-
}
|
|
3536
|
-
for (const layer of layers) {
|
|
3537
|
-
}
|
|
3538
|
-
|
|
3539
|
-
// Resolve file_path → base64 for write operations before sending to server
|
|
3540
|
-
// Separate asset uploads from frame operations
|
|
3541
|
-
const assetOps = operations.filter(op => op.tool === 'upload_asset');
|
|
3542
|
-
const frameOps = operations.filter(op => op.tool !== 'upload_asset');
|
|
3543
|
-
|
|
3544
|
-
const results = [];
|
|
3545
|
-
|
|
3546
|
-
// Handle asset uploads via the asset API
|
|
3547
|
-
for (const op of assetOps) {
|
|
3548
|
-
try {
|
|
3549
|
-
if (!op.asset_path) throw new Error('asset_path is required for upload_asset');
|
|
3550
|
-
if (op.asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
3551
|
-
|
|
3552
|
-
let b64, ct;
|
|
3553
|
-
if (op.file_path) {
|
|
3554
|
-
const resolved = resolve(op.file_path);
|
|
3555
|
-
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
3556
|
-
const buffer = readFileSync(resolved);
|
|
3557
|
-
b64 = buffer.toString('base64');
|
|
3558
|
-
ct = op.content_type || mimeFromExt(extname(resolved));
|
|
3559
|
-
} else if (op.content != null) {
|
|
3560
|
-
b64 = Buffer.from(op.content).toString('base64');
|
|
3561
|
-
ct = op.content_type || mimeFromExt(extname(op.asset_path));
|
|
3562
|
-
} else {
|
|
3563
|
-
throw new Error('upload_asset requires file_path or content');
|
|
3564
|
-
}
|
|
3565
|
-
|
|
3566
|
-
const body = { base64: b64, contentType: ct };
|
|
3567
|
-
if (op.frame_id) body.frameId = op.frame_id;
|
|
3568
|
-
|
|
3569
|
-
const projectId = getState().projectId;
|
|
3570
|
-
if (!projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
3571
|
-
const r = await api('PUT', `/api/projects/${projectId}/assets/${op.asset_path}`, body);
|
|
3572
|
-
results.push({ ok: true, tool: 'upload_asset', asset_path: op.asset_path, ...r });
|
|
3573
|
-
} catch (e) {
|
|
3574
|
-
results.push({ ok: false, tool: 'upload_asset', asset_path: op.asset_path, error: e.message });
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
|
|
3578
|
-
// Handle frame operations via the batch API
|
|
3579
|
-
if (frameOps.length > 0) {
|
|
3580
|
-
const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
|
|
3581
|
-
const resolvedOps = frameOps.map(op => {
|
|
3582
|
-
if (op.tool === 'write' && op.file_path) {
|
|
3583
|
-
const resolved = resolve(op.file_path);
|
|
3584
|
-
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
3585
|
-
const ext = extname(resolved).toLowerCase();
|
|
3586
|
-
const { file_path: _, ...rest } = op;
|
|
3587
|
-
if (TEXT_EXTS.includes(ext)) {
|
|
3588
|
-
return { ...rest, content: readFileSync(resolved, 'utf8') };
|
|
3589
|
-
}
|
|
3590
|
-
const buffer = readFileSync(resolved);
|
|
3591
|
-
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
|
|
3592
|
-
return { ...rest, base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
|
|
3593
|
-
}
|
|
3594
|
-
return op;
|
|
3595
|
-
});
|
|
3596
|
-
|
|
3597
|
-
const batchResult = await api('POST', '/api/fs/batch', { operations: resolvedOps });
|
|
3598
|
-
if (batchResult.results) {
|
|
3599
|
-
for (const r of batchResult.results) {
|
|
3600
|
-
results.push(withFrameBreadcrumb(r));
|
|
3601
|
-
}
|
|
3602
|
-
}
|
|
3603
|
-
}
|
|
3604
|
-
|
|
3605
|
-
return ok({ ok: true, results });
|
|
3606
|
-
} catch (error) { return err(error); }
|
|
3607
|
-
});
|
|
3608
|
-
*/
|
|
3609
|
-
|
|
3610
|
-
// ── Asset tools ──────────────────────────────────────────────────
|
|
3611
|
-
|
|
3612
|
-
tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PROJECT. Assets are referenced by frames via relative paths — e.g., if your HTML has <link href="css/styles.css">, upload with asset_path="css/styles.css". Assets are NOT frames — they don\'t appear on the canvas. `action=upload` to add/replace, `action=list` to browse, `action=rm` to delete.', {
|
|
3613
|
-
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3614
|
-
action: z.enum(['upload', 'list', 'rm']).describe('Operation to perform.'),
|
|
3615
|
-
asset_path: z.string().optional().describe('[upload] relative asset path (e.g. "css/styles.css"). Must match the path used in HTML references.'),
|
|
3616
|
-
file_path: z.string().optional().describe('[upload] absolute path to a local file. Mutually exclusive with content/base64.'),
|
|
3617
|
-
content: z.string().optional().describe('[upload] text content (for CSS/JS). Mutually exclusive with file_path/base64.'),
|
|
3618
|
-
base64: z.string().optional().describe('[upload] base64-encoded binary content. Mutually exclusive with file_path/content.'),
|
|
3619
|
-
content_type: z.string().optional().describe('[upload] MIME type (auto-detected from extension if omitted).'),
|
|
3620
|
-
frame_id: z.string().optional().describe('[upload|list] associate with / filter by a specific frame. Get frame IDs from frame(action="write") or ls.'),
|
|
3621
|
-
}, async (args) => {
|
|
3622
|
-
try {
|
|
3623
|
-
const { action } = args;
|
|
3624
|
-
const projectId = getState().projectId;
|
|
3625
|
-
if (!projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
3626
|
-
if (action === 'upload') {
|
|
3627
|
-
const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
|
|
3628
|
-
if (!asset_path) throw new Error('asset_path is required for action=upload');
|
|
3629
|
-
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
3630
|
-
let b64, ct;
|
|
3631
|
-
if (file_path) {
|
|
3632
|
-
const resolved = resolve(file_path);
|
|
3633
|
-
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
3634
|
-
const buffer = readFileSync(resolved);
|
|
3635
|
-
b64 = buffer.toString('base64');
|
|
3636
|
-
ct = content_type || mimeFromExt(extname(resolved));
|
|
3637
|
-
} else if (content != null) {
|
|
3638
|
-
b64 = Buffer.from(content).toString('base64');
|
|
3639
|
-
ct = content_type || mimeFromExt(extname(asset_path));
|
|
3640
|
-
} else if (rawBase64) {
|
|
3641
|
-
b64 = rawBase64;
|
|
3642
|
-
ct = content_type || mimeFromExt(extname(asset_path));
|
|
3643
|
-
} else {
|
|
3644
|
-
throw new Error('Provide file_path, content, or base64');
|
|
3645
|
-
}
|
|
3646
|
-
const body = { base64: b64, contentType: ct };
|
|
3647
|
-
if (frame_id) body.frameId = frame_id;
|
|
3648
|
-
return ok(await api('PUT', `/api/projects/${projectId}/assets/${asset_path}`, body));
|
|
3649
|
-
}
|
|
3650
|
-
if (action === 'list') {
|
|
3651
|
-
const { frame_id } = args;
|
|
3652
|
-
const query = frame_id ? `?frameId=${frame_id}` : '';
|
|
3653
|
-
return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
|
|
3654
|
-
}
|
|
3655
|
-
if (action === 'rm') {
|
|
3656
|
-
const { asset_path } = args;
|
|
3657
|
-
if (!asset_path) throw new Error('asset_path is required for action=rm');
|
|
3658
|
-
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
3659
|
-
return ok(await api('DELETE', `/api/projects/${projectId}/assets/${asset_path}`));
|
|
3660
|
-
}
|
|
3661
|
-
throw new Error(`Unknown asset action: ${action}`);
|
|
3662
|
-
} catch (error) { return err(error); }
|
|
3663
|
-
});
|
|
3664
|
-
|
|
3665
|
-
// ── Connector tools ───────────────────────────────────────────────
|
|
3666
|
-
|
|
3667
|
-
tool('connector', 'Create or remove connectors (arrows) between frames on the surface. `action=connect` adds an arrow from source to target. `action=disconnect` removes one — pass either connectorId directly or source+target to find and delete.', {
|
|
3668
|
-
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3669
|
-
action: z.enum(['connect', 'disconnect']).describe('Operation to perform.'),
|
|
3670
|
-
source: z.string().optional().describe('[connect|disconnect] source frame path or ID'),
|
|
3671
|
-
target: z.string().optional().describe('[connect|disconnect] target frame path or ID'),
|
|
3672
|
-
label: z.string().optional().describe('[connect] connector label text'),
|
|
3673
|
-
type: z.string().optional().describe('[connect] connector type (default: arrow-forward)'),
|
|
3674
|
-
color: z.string().optional().describe('[connect] connector color (CSS color string)'),
|
|
3675
|
-
connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
|
|
3676
|
-
}, async (args) => {
|
|
3677
|
-
try {
|
|
3678
|
-
const { action } = args;
|
|
3679
|
-
if (action === 'connect') {
|
|
3680
|
-
const { source, target, label, type = 'arrow-forward', color } = args;
|
|
3681
|
-
if (!source || !target) throw new Error('source and target required for action=connect');
|
|
3682
|
-
const body = { source, target };
|
|
3683
|
-
if (label) body.label = label;
|
|
3684
|
-
if (type) body.type = type;
|
|
3685
|
-
if (color) body.color = color;
|
|
3686
|
-
return ok(await api('POST', '/api/connectors', body));
|
|
3687
|
-
}
|
|
3688
|
-
if (action === 'disconnect') {
|
|
3689
|
-
const { source, target, connectorId } = args;
|
|
3690
|
-
if (connectorId) {
|
|
3691
|
-
return ok(await api('DELETE', `/api/connectors/${connectorId}`));
|
|
3692
|
-
}
|
|
3693
|
-
if (source && target) {
|
|
3694
|
-
const sourceFrame = await api('GET', `/api/fs/${source.replace(/^\/+/, '')}`);
|
|
3695
|
-
const targetFrame = await api('GET', `/api/fs/${target.replace(/^\/+/, '')}`);
|
|
3696
|
-
const sourceId = sourceFrame.id;
|
|
3697
|
-
const targetId = targetFrame.id;
|
|
3698
|
-
if (!sourceId || !targetId) throw new Error('Could not resolve source or target frame');
|
|
3699
|
-
const connectors = await api('GET', '/api/connectors');
|
|
3700
|
-
const match = (connectors.connectors || connectors || []).find(
|
|
3701
|
-
c => c.sourceDesignId === sourceId && c.targetDesignId === targetId
|
|
3702
|
-
);
|
|
3703
|
-
if (!match) throw new Error(`No connector found from ${source} to ${target}`);
|
|
3704
|
-
return ok(await api('DELETE', `/api/connectors/${match.id}`));
|
|
3705
|
-
}
|
|
3706
|
-
throw new Error('Provide either connectorId, or both source and target');
|
|
3707
|
-
}
|
|
3708
|
-
throw new Error(`Unknown connector action: ${action}`);
|
|
3709
|
-
} catch (error) { return err(error); }
|
|
3710
|
-
});
|
|
3711
|
-
|
|
3712
|
-
// ── Layout tools ──────────────────────────────────────────────────
|
|
3713
|
-
|
|
3714
|
-
tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions connected frames as a directed graph.', {
|
|
3715
|
-
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3716
|
-
direction: z.enum(['TB', 'LR', 'BT', 'RL']).optional().default('TB').describe('Layout direction: TB (top-bottom), LR (left-right), BT (bottom-top), RL (right-left)'),
|
|
3717
|
-
}, async ({ direction }) => {
|
|
3718
|
-
try {
|
|
3719
|
-
const body = { direction };
|
|
3720
|
-
const result = await api('POST', '/api/layout', body);
|
|
3721
|
-
return ok(result);
|
|
3722
|
-
} catch (error) { return err(error); }
|
|
3723
|
-
});
|
|
3724
|
-
|
|
3725
|
-
// ── Skill library tool ───────────────────────────────────────────
|
|
3726
|
-
|
|
3727
|
-
// Walk a local source tree for `skill action=push` with dir, pre-filtering heavy
|
|
3728
|
-
// dirs + any .skillignore as a convenience so we don't ship a node_modules tree
|
|
3729
|
-
// over the wire. The SERVER re-runs the authoritative hygiene pipeline. Mirrors the
|
|
3730
|
-
// CLI's collectSkillTree.
|
|
3731
|
-
function collectSkillTreeForPush(dir) {
|
|
3732
|
-
const root = resolve(dir);
|
|
3733
|
-
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
|
|
3734
|
-
const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform', '.skillinstall']);
|
|
3735
|
-
const NUL = String.fromCharCode(0);
|
|
3736
|
-
let ignore = () => false;
|
|
3737
|
-
const ign = join(root, '.skillignore');
|
|
3738
|
-
if (existsSync(ign)) {
|
|
3739
|
-
const pats = readFileSync(ign, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
3740
|
-
ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
|
|
3741
|
-
}
|
|
3742
|
-
const out = [];
|
|
3743
|
-
const walk = (abs, rel) => {
|
|
3744
|
-
for (const name of readdirSync(abs)) {
|
|
3745
|
-
const childAbs = join(abs, name);
|
|
3746
|
-
const childRel = rel ? `${rel}/${name}` : name;
|
|
3747
|
-
const st = statSync(childAbs);
|
|
3748
|
-
if (st.isDirectory()) { if (SKIP.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
|
|
3749
|
-
else if (st.isFile()) {
|
|
3750
|
-
if (ignore(childRel)) continue;
|
|
3751
|
-
const content = readFileSync(childAbs, 'utf8');
|
|
3752
|
-
if (content.includes(NUL)) continue; // skip binary; server rejects it anyway
|
|
3753
|
-
out.push({ path: childRel, content });
|
|
3754
|
-
}
|
|
3755
|
-
}
|
|
3756
|
-
};
|
|
3757
|
-
walk(root, '');
|
|
3758
|
-
return out;
|
|
3759
|
-
}
|
|
3760
|
-
|
|
3761
|
-
// Ensure a pushed source tree's .gitignore excludes the rebuildable bundle dir, so
|
|
3762
|
-
// a skill's machine-specific build output (built into .skillinstall/ by its `setup:`
|
|
3763
|
-
// recipe) can never be committed. The server also strips .skillinstall/ from the
|
|
3764
|
-
// bundle (skill-ingest DENY_DIRS); this keeps the author's git clean. Idempotent.
|
|
3765
|
-
function ensureSkillInstallIgnored(dir) {
|
|
3766
|
-
try {
|
|
3767
|
-
const gi = join(dir, '.gitignore');
|
|
3768
|
-
const existing = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
3769
|
-
if (existing.split(/\r?\n/).some((l) => l.trim().replace(/\/$/, '') === '.skillinstall')) return false;
|
|
3770
|
-
const body = existing && !existing.endsWith('\n') ? existing + '\n' : existing;
|
|
3771
|
-
writeFileSync(gi, body + '.skillinstall/\n');
|
|
3772
|
-
return true;
|
|
3773
|
-
} catch { return false; }
|
|
3774
|
-
}
|
|
3775
|
-
|
|
3776
|
-
tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. When you build a reusable skill, author it HERE — add for prose, push to ingest a local source tree — so its method and setup: recipe live in Drafted and any machine or agent can reuse it; keep only machine-specific build output local in .skillinstall/ (always stripped on push). Dispatch by `action`: search/load/list for discovery; history for a skill\'s version git-log; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory; export/import to exchange the library as an OKF v0.1 bundle (skills/<slug>/SKILL.md layout).', {
|
|
3777
|
-
action: z.enum([
|
|
3778
|
-
'search', 'load', 'list', 'history',
|
|
3779
|
-
'add', 'update', 'remove',
|
|
3780
|
-
'fork', 'push',
|
|
3781
|
-
'attach', 'detach',
|
|
3782
|
-
'favorite', 'unfavorite',
|
|
3783
|
-
'read_file', 'update_file',
|
|
3784
|
-
'export', 'import',
|
|
3785
|
-
]).describe('Operation to perform. export: the skill library as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest the SKILL.md-shaped concepts of an OKF bundle as org skills (files[] or local dir; dryRun supported).'),
|
|
3786
|
-
query: z.string().optional().describe('[search] term to match against name/description/content'),
|
|
3787
|
-
tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
|
|
3788
|
-
scope: z.enum(['all', 'org', 'global']).optional().describe('[search|list] library scope (default: all for search; when provided to list, lists the library instead of project/org attachments)'),
|
|
3789
|
-
limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100); [export] files per page for format="files" (default 100, max 500)'),
|
|
3790
|
-
compact: z.boolean().optional().describe('[search|list] return only {slug,name,tags} per skill instead of full summaries — for browsing large catalogs within token budget; [export] with format="files": file paths only (no content)'),
|
|
3791
|
-
skill: z.string().optional().describe('[load|history] skill ID (UUID) or slug'),
|
|
3792
|
-
version: z.number().optional().describe('[history] fetch this version number\'s full snapshot (content included); omit for the reverse-chron version list'),
|
|
3793
|
-
skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
|
|
3794
|
-
projectId: z.string().optional().describe('[list] project to list skills for (defaults to active project; falls back to org-attached skills if no project)'),
|
|
3795
|
-
name: z.string().optional().describe('[add|update] skill name'),
|
|
3796
|
-
description: z.string().optional().describe('[add|update] one-line description'),
|
|
3797
|
-
content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
|
|
3798
|
-
triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
|
|
3799
|
-
path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
|
|
3800
|
-
offset: z.number().optional().describe('[search|list|export] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
|
|
3801
|
-
maxBytes: z.number().optional().describe('[read_file] return at most this many bytes from offset (default: whole remaining file). Response reports totalSize/offset/truncated/nextOffset.'),
|
|
3802
|
-
org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update|export|import] resolve/fork into this org. Per-request only — nothing is switched. NO PROJECT IS NEEDED: for project-less skill work either pass org= here, or bind the session once with get_org(action="use", org="<name>"). A multi-org caller that addresses neither is refused rather than guessed.'),
|
|
3803
|
-
setup: z.array(z.string()).optional().describe('[add|update] setup command(s) (in order) run on materialize to build a source-only skill, e.g. ["npm ci","npm run build"]'),
|
|
3804
|
-
files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps. [import] OKF bundle files inline — skills/<slug>/SKILL.md dirs and Skill/Playbook/SOP/Procedure-typed .md with name + description frontmatter become org skills. Caps: 500 files, 512KB/file, 5MB total.'),
|
|
3805
|
-
dir: z.string().optional().describe('[push|export|import] local directory. push: source tree to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces; the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle). export: write the OKF bundle files here (default ./okf-skills-<org>). import: read the bundle from here (alternative to files[]).'),
|
|
3806
|
-
deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
|
|
3807
|
-
format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
|
|
3808
|
-
includeGlobal: z.boolean().optional().describe('[export] include global built-in skills in the bundle (default: org skills only)'),
|
|
3809
|
-
dryRun: z.boolean().optional().describe('[import] report {created, updated, skips, warnings} without writing'),
|
|
3810
|
-
}, async (args) => {
|
|
3811
|
-
try {
|
|
3812
|
-
const { action } = args;
|
|
3813
|
-
// Org-creating/editing skill actions on the project-less surface must have a
|
|
3814
|
-
// bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
|
|
3815
|
-
// UUID-first exception: update/remove addressed by a skillId derive their org
|
|
3816
|
-
// from the skill row server-side, so the bound-org gate is unnecessary.
|
|
3817
|
-
// `fork` (and the fork-on-403 branch inside `update`) run the same guard at
|
|
3818
|
-
// fork time, inside their cases — a fork is a create, governed by the one rule.
|
|
3819
|
-
if (['add', 'update', 'remove', 'push', 'import'].includes(action)) {
|
|
3820
|
-
const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
|
|
3821
|
-
if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
|
|
3822
|
-
}
|
|
3823
|
-
switch (action) {
|
|
3824
|
-
case 'search': {
|
|
3825
|
-
const { query, tags, scope = 'all', limit, offset, compact, org } = args;
|
|
3826
|
-
const params = new URLSearchParams();
|
|
3827
|
-
if (query) params.set('q', query);
|
|
3828
|
-
if (tags?.length) params.set('tags', tags.join(','));
|
|
3829
|
-
if (scope) params.set('scope', scope);
|
|
3830
|
-
const qs = params.toString();
|
|
3831
|
-
const endpoint = query ? '/api/skills/search' : '/api/skills';
|
|
3832
|
-
const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`, undefined, org ? { 'X-Drafted-Org': org } : {});
|
|
3833
|
-
markSearched(getSessionState().gates, 'skill');
|
|
3834
|
-
return ok(shapeSkillCatalog(result, { limit, offset, compact }));
|
|
3835
|
-
}
|
|
3836
|
-
case 'load': {
|
|
3837
|
-
const { skill } = args;
|
|
3838
|
-
if (!skill) throw new Error('skill (ID or slug) is required for action=load');
|
|
3839
|
-
const isUuid = /^[a-f0-9-]{36}$/.test(skill);
|
|
3840
|
-
const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
|
|
3841
|
-
const result = await api('GET', endpoint, undefined, args.org ? { 'X-Drafted-Org': args.org } : {});
|
|
3842
|
-
if (result?.id) getSessionState().loadedSkillIds.add(result.id);
|
|
3843
|
-
return ok(result);
|
|
3844
|
-
}
|
|
3845
|
-
case 'history': {
|
|
3846
|
-
// Skill version git-log. Omit `version` for the list; pass it for a
|
|
3847
|
-
// single version's full snapshot (content for view/diff).
|
|
3848
|
-
const { skillId, skill: slugArg, version, org } = args;
|
|
3849
|
-
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
3850
|
-
let id = skillId;
|
|
3851
|
-
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
|
|
3852
|
-
if (!id) throw new Error('skillId or skill (slug) required for action=history');
|
|
3853
|
-
if (version != null) return ok(await api('GET', `/api/skills/${id}/versions/${version}`, undefined, extra));
|
|
3854
|
-
return ok(await api('GET', `/api/skills/${id}/versions`, undefined, extra));
|
|
3855
|
-
}
|
|
3856
|
-
case 'list': {
|
|
3857
|
-
if (args.scope || args.tags?.length || args.org) {
|
|
3858
|
-
const params = new URLSearchParams();
|
|
3859
|
-
params.set('scope', args.scope || (args.org ? 'org' : 'all'));
|
|
3860
|
-
if (args.tags?.length) params.set('tags', args.tags.join(','));
|
|
3861
|
-
const result = await api('GET', `/api/skills?${params.toString()}`, undefined, args.org ? { 'X-Drafted-Org': args.org } : {});
|
|
3862
|
-
return ok(shapeSkillCatalog(result, { limit: args.limit, offset: args.offset, compact: args.compact }));
|
|
3863
|
-
}
|
|
3864
|
-
|
|
3865
|
-
// Prefer the explicit projectId param; otherwise the active project;
|
|
3866
|
-
// otherwise fall back to org-attached skills so list works in
|
|
3867
|
-
// empty-org / wiki-only sessions where there's no project to bind to.
|
|
3868
|
-
const explicit = args.projectId;
|
|
3869
|
-
const active = getState().projectId;
|
|
3870
|
-
const pid = explicit || active;
|
|
3871
|
-
if (pid) {
|
|
3872
|
-
try {
|
|
3873
|
-
return ok(await api('GET', `/api/projects/${pid}/skills`));
|
|
3874
|
-
} catch (e) {
|
|
3875
|
-
// 404 / "not found" means the project isn't visible in the
|
|
3876
|
-
// session's org (typical after binding to a project in another org). Fall through to
|
|
3877
|
-
// org-attached skills rather than bubbling a useless error.
|
|
3878
|
-
if (!/not found/i.test(e.message)) throw e;
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
|
-
const orgId = await getCurrentOrgId();
|
|
3882
|
-
if (!orgId) throw new Error('No org context. Open a project (project(action="open")), pass projectId, or pass org=.');
|
|
3883
|
-
return ok(await api('GET', `/api/orgs/${orgId}/skills`));
|
|
3884
|
-
}
|
|
3885
|
-
case 'add': {
|
|
3886
|
-
const g2 = g2Block(getSessionState().gates);
|
|
3887
|
-
if (g2) return err(new Error(g2));
|
|
3888
|
-
const { name, description, content, tags, triggerPatterns, setup, org } = args;
|
|
3889
|
-
if (!name || !description || !content) throw new Error('name, description, content required for action=add');
|
|
3890
|
-
const body = { name, description, content };
|
|
3891
|
-
if (tags) body.tags = tags;
|
|
3892
|
-
if (triggerPatterns) body.triggerPatterns = triggerPatterns;
|
|
3893
|
-
if (setup !== undefined) body.setup = setup;
|
|
3894
|
-
// org names where the skill is born (UUID-first model: org is explicit at
|
|
3895
|
-
// create) — without this the bound-org guard accepts org= but the write
|
|
3896
|
-
// would land in the session org anyway.
|
|
3897
|
-
const addedSkill = await api('POST', '/api/skills', body, org ? { 'X-Drafted-Org': org } : {});
|
|
3898
|
-
return ok({ ...addedSkill, ...(await orgEcho(addedSkill, org)) });
|
|
3899
|
-
}
|
|
3900
|
-
case 'update': {
|
|
3901
|
-
// Auto-fork-on-update (tool behavior): if the target is read-only (global/
|
|
3902
|
-
// system or another org), the server returns 403 {code:"skill_read_only"};
|
|
3903
|
-
// we fork into the caller's org and write the copy, returning forked:true.
|
|
3904
|
-
// The raw PUT stays strict — this composition lives in the tool, mirroring
|
|
3905
|
-
// the CLI verbatim so consumers treat CLI and MCP identically.
|
|
3906
|
-
const { skillId, skill: slugArg, name, description, content, tags, triggerPatterns, setup, org } = args;
|
|
3907
|
-
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
3908
|
-
let id = skillId;
|
|
3909
|
-
let slug = slugArg;
|
|
3910
|
-
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; slug = s.slug; }
|
|
3911
|
-
if (!id) throw new Error('skillId or skill (slug) required for action=update');
|
|
3912
|
-
const body = {};
|
|
3913
|
-
if (name !== undefined) body.name = name;
|
|
3914
|
-
if (description !== undefined) body.description = description;
|
|
3915
|
-
if (content !== undefined) body.content = content;
|
|
3916
|
-
if (tags !== undefined) body.tags = tags;
|
|
3917
|
-
if (triggerPatterns !== undefined) body.triggerPatterns = triggerPatterns;
|
|
3918
|
-
if (setup !== undefined) body.setup = setup;
|
|
3919
|
-
if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
|
|
3920
|
-
try {
|
|
3921
|
-
const r = await api('PUT', `/api/skills/${id}`, body, extra);
|
|
3922
|
-
return ok({ ...r, forked: false, ...(await orgEcho(r, org)) });
|
|
3923
|
-
} catch (e) {
|
|
3924
|
-
if (e.code !== 'skill_read_only') throw e;
|
|
3925
|
-
// Read-only skill → the update becomes a FORK (a create). Its org must be a
|
|
3926
|
-
// real root: explicit org=, the active project, or a single-org user's only
|
|
3927
|
-
// org — else, multi-org with nothing bound, error rather than guess. The
|
|
3928
|
-
// receipt names the resulting org so a surprising fork is visible (Reading A).
|
|
3929
|
-
await requireBoundOrgForProjectlessMutation(org);
|
|
3930
|
-
let forkId;
|
|
3931
|
-
try {
|
|
3932
|
-
forkId = (await api('POST', `/api/skills/${id}/fork`, {}, extra)).id;
|
|
3933
|
-
} catch (fe) {
|
|
3934
|
-
if (fe.status !== 409) throw fe;
|
|
3935
|
-
const ownSlug = (fe.body && fe.body.slug) || (e.body && e.body.slug) || slug;
|
|
3936
|
-
forkId = (await api('GET', `/api/skills/slug/${ownSlug}`, undefined, extra)).id;
|
|
3937
|
-
}
|
|
3938
|
-
const r2 = await api('PUT', `/api/skills/${forkId}`, body, extra);
|
|
3939
|
-
return ok({ ...r2, forked: true, ...(await orgEcho(r2, org)) });
|
|
3940
|
-
}
|
|
3941
|
-
}
|
|
3942
|
-
case 'remove': {
|
|
3943
|
-
const { skillId } = args;
|
|
3944
|
-
if (!skillId) throw new Error('skillId required for action=remove');
|
|
3945
|
-
return ok(await api('DELETE', `/api/skills/${skillId}`));
|
|
3946
|
-
}
|
|
3947
|
-
case 'fork': {
|
|
3948
|
-
// Block-then-fork: copy a readable (global/other-org) skill into the
|
|
3949
|
-
// caller's org so it can be edited. Mirrors `drafted skill fork` verbatim.
|
|
3950
|
-
const { skillId, skill: slugArg, org } = args;
|
|
3951
|
-
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
3952
|
-
let id = skillId;
|
|
3953
|
-
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
|
|
3954
|
-
if (!id) throw new Error('skillId or skill (slug) required for action=fork');
|
|
3955
|
-
// Forking creates a new skill — same org rule as any create (explicit org=,
|
|
3956
|
-
// active project, or single-org; else refuse to guess). Receipt names the org.
|
|
3957
|
-
await requireBoundOrgForProjectlessMutation(org);
|
|
3958
|
-
try {
|
|
3959
|
-
const forkedSkill = await api('POST', `/api/skills/${id}/fork`, {}, extra);
|
|
3960
|
-
return ok({ ...forkedSkill, ...(await orgEcho(forkedSkill, org)) });
|
|
3961
|
-
} catch (e) {
|
|
3962
|
-
if (e.status === 409) return ok({ status: 'conflict', error: e.message }); // org already owns this slug
|
|
3963
|
-
throw e;
|
|
3964
|
-
}
|
|
3965
|
-
}
|
|
3966
|
-
case 'push': {
|
|
3967
|
-
// Source-tree ingest. Pass files:[{path,content}] (the usual MCP shape) or a
|
|
3968
|
-
// local dir (walked here, server re-enforces the §3 ingest guards). Mirrors
|
|
3969
|
-
// `drafted skill push`.
|
|
3970
|
-
const { skillId, skill: slugArg, files, dir, deleteMissing, org } = args;
|
|
3971
|
-
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
3972
|
-
let id = skillId;
|
|
3973
|
-
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
|
|
3974
|
-
if (!id) throw new Error('skillId or skill (slug) required for action=push');
|
|
3975
|
-
let fileList = files;
|
|
3976
|
-
if (!fileList && dir) fileList = collectSkillTreeForPush(dir);
|
|
3977
|
-
if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
|
|
3978
|
-
const pushed = await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra);
|
|
3979
|
-
if (dir) { try { if (ensureSkillInstallIgnored(dir)) pushed.gitignored = '.skillinstall/'; } catch { /* best-effort */ } }
|
|
3980
|
-
return ok({ ...pushed, ...(await orgEcho(pushed, org)) });
|
|
3981
|
-
}
|
|
3982
|
-
case 'attach': {
|
|
3983
|
-
const { skillId } = args;
|
|
3984
|
-
if (!skillId) throw new Error('skillId required for action=attach');
|
|
3985
|
-
if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
3986
|
-
// Cap (G4): keep attached skills + anchors within the per-project budget so
|
|
3987
|
-
// the auto-inject set always fits — reject the attach if it would overflow.
|
|
3988
|
-
try {
|
|
3989
|
-
const used = await getProjectPrimingSize(getState().projectId);
|
|
3990
|
-
let addSize = 0;
|
|
3991
|
-
try { const full = await api('GET', `/api/skills/${skillId}`); addSize = (full?.content || '').length; } catch { /* unknown size */ }
|
|
3992
|
-
if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this skill')));
|
|
3993
|
-
} catch { /* size check is best-effort */ }
|
|
3994
|
-
const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
|
|
3995
|
-
return ok(result);
|
|
3996
|
-
}
|
|
3997
|
-
case 'detach': {
|
|
3998
|
-
const { skillId } = args;
|
|
3999
|
-
if (!skillId) throw new Error('skillId required for action=detach');
|
|
4000
|
-
if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
4001
|
-
const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
|
|
4002
|
-
return ok(result);
|
|
4003
|
-
}
|
|
4004
|
-
case 'favorite': {
|
|
4005
|
-
const { skillId } = args;
|
|
4006
|
-
if (!skillId) throw new Error('skillId required for action=favorite');
|
|
4007
|
-
return ok(await api('POST', `/api/skills/favorites/${skillId}`));
|
|
4008
|
-
}
|
|
4009
|
-
case 'unfavorite': {
|
|
4010
|
-
const { skillId } = args;
|
|
4011
|
-
if (!skillId) throw new Error('skillId required for action=unfavorite');
|
|
4012
|
-
return ok(await api('DELETE', `/api/skills/favorites/${skillId}`));
|
|
4013
|
-
}
|
|
4014
|
-
case 'read_file': {
|
|
4015
|
-
const { skillId, path, offset, maxBytes } = args;
|
|
4016
|
-
if (!skillId || !path) throw new Error('skillId and path required for action=read_file');
|
|
4017
|
-
const params = new URLSearchParams();
|
|
4018
|
-
if (offset != null) params.set('offset', String(offset));
|
|
4019
|
-
if (maxBytes != null) params.set('maxBytes', String(maxBytes));
|
|
4020
|
-
const qs = params.toString();
|
|
4021
|
-
return ok(await api('GET', `/api/skills/${skillId}/files/${path}${qs ? `?${qs}` : ''}`));
|
|
4022
|
-
}
|
|
4023
|
-
case 'update_file': {
|
|
4024
|
-
const { skillId, path, content } = args;
|
|
4025
|
-
if (!skillId || !path || content == null) throw new Error('skillId, path, content required for action=update_file');
|
|
4026
|
-
return ok(await api('PUT', `/api/skills/${skillId}/files/${path}`, { content }));
|
|
4027
|
-
}
|
|
4028
|
-
|
|
4029
|
-
// ── export ──────────────────────────────────────────────────
|
|
4030
|
-
// The skill library as an OKF v0.1 bundle (skills/<slug>/SKILL.md +
|
|
4031
|
-
// supporting files + synthesized log.md/index.md). Mirrors wiki export:
|
|
4032
|
-
// format="files" pages the bundle inline; otherwise stdio writes a local
|
|
4033
|
-
// dir, remote returns the authenticated tar.gz download URL.
|
|
4034
|
-
case 'export': {
|
|
4035
|
-
const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
|
|
4036
|
-
const inc = args.includeGlobal ? '&includeGlobal=1' : '';
|
|
4037
|
-
if (args.format === 'files') {
|
|
4038
|
-
const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
|
|
4039
|
-
if (args.offset) qp.set('offset', String(args.offset));
|
|
4040
|
-
if (args.compact) qp.set('compact', 'true');
|
|
4041
|
-
return ok(await api('GET', `/api/skills/export?${qp.toString()}${inc}`, undefined, extra));
|
|
4042
|
-
}
|
|
4043
|
-
if (isRemote) {
|
|
4044
|
-
return ok({
|
|
4045
|
-
downloadUrl: `${getServerUrl()}/api/skills/export.tar.gz${args.includeGlobal ? '?includeGlobal=1' : ''}`,
|
|
4046
|
-
note: 'Open the URL in a signed-in browser to download the OKF v0.1 skill bundle, or call export with format="files" to page the bundle contents inline.',
|
|
4047
|
-
});
|
|
4048
|
-
}
|
|
4049
|
-
// stdio: write every bundle file under a local directory.
|
|
4050
|
-
const skOrgCtx = await getCurrentOrgContext();
|
|
4051
|
-
const skOrgSlug = String(skOrgCtx?.name || skOrgCtx?.id || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
|
|
4052
|
-
const exportDir = resolve(args.dir || `./okf-skills-${skOrgSlug}`);
|
|
4053
|
-
let expOffset = 0;
|
|
4054
|
-
let written = 0;
|
|
4055
|
-
for (;;) {
|
|
4056
|
-
const batch = await api('GET', `/api/skills/export?limit=200&offset=${expOffset}${inc}`, undefined, extra);
|
|
4057
|
-
const batchFiles = batch.files || [];
|
|
4058
|
-
for (const f of batchFiles) {
|
|
4059
|
-
const dest = resolve(exportDir, f.path);
|
|
4060
|
-
if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
|
|
4061
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
4062
|
-
writeFileSync(dest, f.content, 'utf8');
|
|
4063
|
-
written++;
|
|
4064
|
-
}
|
|
4065
|
-
expOffset += batchFiles.length;
|
|
4066
|
-
if (!batch.truncated || batchFiles.length === 0) break;
|
|
4067
|
-
}
|
|
4068
|
-
return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 skill bundle written (skills/<slug>/SKILL.md layout; index.md and log.md are synthesized).' });
|
|
4069
|
-
}
|
|
4070
|
-
|
|
4071
|
-
// ── import ──────────────────────────────────────────────────
|
|
4072
|
-
// Ingest the SKILL.md-shaped concepts of an OKF bundle as org skills:
|
|
4073
|
-
// inline files[] or (stdio) a local dir walked with the push filters.
|
|
4074
|
-
// Use wiki(action="import") for a mixed knowledge bundle — it routes
|
|
4075
|
-
// skill concepts here and everything else into the wiki.
|
|
4076
|
-
case 'import': {
|
|
4077
|
-
const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
|
|
4078
|
-
let importFiles = args.files;
|
|
4079
|
-
if (!importFiles && args.dir) importFiles = collectSkillTreeForPush(args.dir);
|
|
4080
|
-
if (!Array.isArray(importFiles) || importFiles.length === 0) {
|
|
4081
|
-
throw new Error('import requires files[] (or dir on stdio) with at least one file');
|
|
4082
|
-
}
|
|
4083
|
-
return ok(await api('POST', '/api/skills/import', { files: importFiles, dryRun: !!args.dryRun }, extra));
|
|
4084
|
-
}
|
|
4085
|
-
|
|
4086
|
-
default:
|
|
4087
|
-
throw new Error(`Unknown skill action: ${action}`);
|
|
4088
|
-
}
|
|
4089
|
-
} catch (error) { return err(error); }
|
|
4090
|
-
});
|
|
4091
|
-
|
|
4092
|
-
// ── Wiki tool (org-scoped, no project needed) ─────────────────────
|
|
4093
|
-
// All 11 actions dispatch from one tool. Read-only actions skip the
|
|
4094
|
-
// skill gate; mutations require org-level wiki-maintainer skills loaded.
|
|
4095
|
-
|
|
4096
|
-
tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\n**Addressing:** a `pageId` (UUID) self-derives its org — no org arg needed. Path-based and listing actions scope to the open project\'s org by default; pass `org=...` to target another org (there is no org switching). `search` spans ALL your orgs by default — don\'t assume "no hits" means the content doesn\'t exist.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry. Reference sources with `cite` (appends a numbered entry to the page\'s `# Citations` section).\n\nThe wiki is an OKF v0.1 bundle (Open Knowledge Format): every page carries frontmatter with a `type` (default "Page"), a one-line `description` is recommended, links may use `/a/b.md` or extensionless `a/b` form, and `index.md` at any level is synthesized — read it for a directory listing, never write it. Exchange whole bundles with `export` (conformant tar.gz / local dir / paged files) and `import` (files[] or local dir, dryRun supported).\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
|
|
4097
|
-
action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'cite', 'source-register', 'source-list', 'source-get', 'bulk-write', 'export', 'import']).describe('Operation to perform. export: the whole wiki as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest an OKF bundle (files[] or local dir; dryRun supported).'),
|
|
4098
|
-
path: z.string().optional().describe('[ls|read|links|cite] wiki path. For ls: default / (root). For read: required. For links/cite: required unless pageId given. Reading `index.md` (any level) returns the SYNTHESIZED OKF directory listing.'),
|
|
4099
|
-
pageId: z.string().optional().describe('[read|edit|mv|rm|links] page UUID (from read/search). UUID-first: addresses the page directly, org auto-derives — no org needed and no path lookup. Preferred over path for an existing page.'),
|
|
4100
|
-
org: z.string().optional().describe('Org slug or id to scope this call to (per-request only — nothing is switched). NO PROJECT IS NEEDED for wiki work: to write project-less, either pass org= here, or bind the session once with get_org(action="use", org="<name>") and then omit it. Multi-org callers MUST address the org one of those two ways — an unaddressed write is refused rather than guessed (it would land in whichever org the session inherited). [write] the org the page is created in. [search] restrict to this org (default: ALL your orgs). [ls|recent|read|links|log|health|edit|mv|rm|bulk-write] target this org\'s wiki instead of the open project\'s org. Ignored when a pageId is given (the page self-derives its org).'),
|
|
4101
|
-
recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
|
|
4102
|
-
limit: z.number().optional().describe('[recent|search|export] max results (recent default 10, search default 25, export files default 100)'),
|
|
4103
|
-
offset: z.number().optional().describe('[export] pagination offset for format="files"'),
|
|
4104
|
-
compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
|
|
4105
|
-
format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
|
|
4106
|
-
files: z.array(z.object({
|
|
4107
|
-
path: z.string().describe('Bundle-relative file path, e.g. "concepts/frames.md"'),
|
|
4108
|
-
content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
|
|
4109
|
-
})).optional().describe('[import] OKF bundle files inline. index.md files are skipped (synthesized), bundle-root log.md merges into the wiki log page. Caps: 500 files, 512KB/file, 5MB total.'),
|
|
4110
|
-
...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-export-<org>). import: recursively read .md files from here (alternative to files[]).') }),
|
|
4111
|
-
query: z.string().optional().describe('[search] term to search in title, path, and content'),
|
|
4112
|
-
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
4113
|
-
message: z.string().optional().describe('[log] message to append to the log page (OKF date-grouped format)'),
|
|
4114
|
-
verb: z.string().optional().describe('[log] leading bold verb for the entry: Update (default), Creation, Deprecation, or Initialization.'),
|
|
4115
|
-
title: z.string().optional().describe('[write] page title (required for write)'),
|
|
4116
|
-
content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
|
|
4117
|
-
type: z.string().optional().describe('[write] OKF concept type — free-form string, e.g. "Page", "Reference", "Playbook", "Decision", "Metric". Defaults to "Page" on create; must stay non-empty on update (OKF v0.1 requires a type on every page).'),
|
|
4118
|
-
raw: z.boolean().optional().describe('[read] return content bytes-as-stored (skip the synthesized OKF frontmatter block). Do NOT build edit operations from a raw read — hashline anchors for edit must come from a normal (non-raw) read.'),
|
|
4119
|
-
url: z.string().optional().describe('[cite] citation URL (required for cite)'),
|
|
4120
|
-
label: z.string().optional().describe('[cite] link label for the citation (defaults to the URL)'),
|
|
4121
|
-
frontmatter: z.any().optional().describe('[write] frontmatter object. Recommended OKF keys: a one-line "description" (index listings and consumers read it), "tags" (list), "resource" (canonical URI of the underlying asset). Unknown keys are preserved; never set "timestamp" (synthesized from the last update).'),
|
|
4122
|
-
operations: z.array(z.object({
|
|
4123
|
-
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
4124
|
-
lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash.'),
|
|
4125
|
-
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
4126
|
-
})).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
|
|
4127
|
-
from: z.string().optional().describe('[mv] source path'),
|
|
4128
|
-
to: z.string().optional().describe('[mv] destination path'),
|
|
4129
|
-
dryRun: z.boolean().optional().describe('[mv|rm|import] preview impact without applying changes (import: returns the {creates, updates, skips, warnings} report without writing)'),
|
|
4130
|
-
file_path: z.string().optional().describe('[source-register] absolute path to a local file. Server hashes it and registers the source. stdio MCP only.'),
|
|
4131
|
-
contentHash: z.string().optional().describe('[source-register|source-list|cite] hex-encoded SHA-256 (64 chars). Use when the client already hashed the bytes (HTTP MCP). For cite: also registers the cited source.'),
|
|
4132
|
-
filename: z.string().optional().describe('[source-register|cite] original filename for the source (informational)'),
|
|
4133
|
-
contentType: z.string().optional().describe('[source-register] MIME type (informational)'),
|
|
4134
|
-
size: z.number().optional().describe('[source-register] byte size (informational)'),
|
|
4135
|
-
sourceId: z.string().optional().describe('[source-get] source ID returned from source-register'),
|
|
4136
|
-
manifest_path: z.string().optional().describe('[bulk-write] absolute path to a JSON file containing {pages: [{path, title, content, type?, frontmatter?}, ...]}. The MCP wrapper reads the file locally and posts the array to the server in a single request — avoids round-tripping every page through tool args.'),
|
|
4137
|
-
pages: z.array(z.any()).optional().describe('[bulk-write] alternative to manifest_path: pass the pages array inline.'),
|
|
4138
|
-
}, async (args) => {
|
|
4139
|
-
try {
|
|
4140
|
-
const { action } = args;
|
|
4141
|
-
|
|
4142
|
-
// UUID-first: a `pageId` (edit/mv/rm) or an explicit `org` (write) makes the
|
|
4143
|
-
// target org unambiguous, so the bound-org gate is unnecessary — skip it.
|
|
4144
|
-
// Otherwise (path-addressed, multi-org, nothing bound) the gate still
|
|
4145
|
-
// refuses to guess the org so a write never silently lands in the wrong one.
|
|
4146
|
-
const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
|
|
4147
|
-
if (['write', 'edit', 'mv', 'rm', 'bulk-write', 'cite', 'import'].includes(action)) {
|
|
4148
|
-
if (!args.pageId) await requireBoundOrgForProjectlessMutation(args.org);
|
|
4149
|
-
}
|
|
4150
|
-
|
|
4151
|
-
// Resolve org context for this CALL. An explicit org= override wins (per
|
|
4152
|
-
// request, nothing is switched); otherwise the session's binding (the open
|
|
4153
|
-
// project's org). Resolving the override here keeps the echoed `org` field
|
|
4154
|
-
// and every emitted browser URL truthful about where the call landed.
|
|
4155
|
-
// Where the write will ACTUALLY land: the working org (explicit switch, else the
|
|
4156
|
-
// bound project's org — the same address api() puts on the wire). getCurrentOrgContext
|
|
4157
|
-
// reports the session's INHERITED org, so echoing it made a correctly-placed write
|
|
4158
|
-
// look misfiled — and an agent trusting that echo would "fix" a page that was fine.
|
|
4159
|
-
const working = workingOrgId();
|
|
4160
|
-
// Cached for 30s, so this is a cache hit on all but the first wiki call in a
|
|
4161
|
-
// session — cheap enough to always have on hand for the receipt below.
|
|
4162
|
-
const orgList = await getOrgList();
|
|
4163
|
-
let orgCtx = working
|
|
4164
|
-
? (orgList.find(o => o.id === working) || { id: working, name: null })
|
|
4165
|
-
: await getCurrentOrgContext();
|
|
4166
|
-
if (args.org) {
|
|
4167
|
-
const d = await api('GET', '/api/orgs');
|
|
4168
|
-
const list = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
4169
|
-
const wanted = String(args.org).toLowerCase();
|
|
4170
|
-
const byName = list.filter(o => (o.name || '').toLowerCase() === wanted);
|
|
4171
|
-
const match = list.find(o => o.id === args.org) || (byName.length === 1 ? byName[0] : null);
|
|
4172
|
-
if (match) orgCtx = match;
|
|
4173
|
-
}
|
|
4174
|
-
const orgId = orgCtx?.id || null;
|
|
4175
|
-
// Mutation responses include `org` so the agent always sees where the
|
|
4176
|
-
// write landed — eliminates silent cross-org confusion.
|
|
4177
|
-
//
|
|
4178
|
-
// A UUID-addressed resource (pageId=…) SELF-DERIVES its org server-side, so
|
|
4179
|
-
// for those the write can land in an org this session isn't bound to. Echoing
|
|
4180
|
-
// the session's working org there is the same defect the URL builder had: the
|
|
4181
|
-
// receipt would name one org while `url` pointed at another. Prefer the org
|
|
4182
|
-
// carried on the returned row, and fall back to the session context only when
|
|
4183
|
-
// the response doesn't name one.
|
|
4184
|
-
const withOrg = (result, resourceOrgId) => ({
|
|
4185
|
-
...result,
|
|
4186
|
-
org: receiptOrg({ resourceOrgId: resourceOrgId || result?.orgId, sessionOrg: orgCtx, orgList }),
|
|
4187
|
-
});
|
|
4188
|
-
// Org-qualify every browser URL this tool emits (shadows the module fn for
|
|
4189
|
-
// all call sites below) so links are portable across the viewer's orgs.
|
|
4190
|
-
// A UUID-addressed page SELF-DERIVES its org server-side, so its URL must be
|
|
4191
|
-
// built from the org on the returned row (`resourceOrg`), not from this session's
|
|
4192
|
-
// working org — otherwise `wiki(action="read", pageId=…)` hands back a link into
|
|
4193
|
-
// whatever org the session happens to be bound to, for a page that lives elsewhere.
|
|
4194
|
-
// Path-addressed calls have no resource org and correctly fall back to `orgId`.
|
|
4195
|
-
const wikiBrowserUrl = (p, resourceOrg) => wikiPageUrl(p, resourceOrg || orgId);
|
|
4196
|
-
|
|
4197
|
-
// ── Skill gate: all mutation actions ──────────────────────────
|
|
4198
|
-
// Ensure the wiki-maintainer skill is attached to this org BEFORE the
|
|
4199
|
-
// gate check, so the gate fires reliably on the very first wiki call —
|
|
4200
|
-
// not just after the org has visited /wiki in a browser. Idempotent.
|
|
4201
|
-
const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'cite', 'source-register', 'bulk-write', 'import']);
|
|
4202
|
-
if (MUTATING.has(action)) {
|
|
4203
|
-
try { await api('POST', '/api/wiki/_ensure-skill'); } catch { /* non-fatal */ }
|
|
4204
|
-
const skillErr = await checkOrgSkills(orgId, action);
|
|
4205
|
-
if (skillErr) return err(new Error(skillErr));
|
|
4206
|
-
}
|
|
4207
|
-
|
|
4208
|
-
switch (action) {
|
|
4209
|
-
|
|
4210
|
-
// ── ls ──────────────────────────────────────────────────────
|
|
4211
|
-
case 'ls': {
|
|
4212
|
-
const { path: lsPath = '/', recursive: lsRecursive = false } = args;
|
|
4213
|
-
const { pages, pathToPage } = await getTreeAsMap(orgHeader);
|
|
4214
|
-
const parent = normalizeWikiPath(lsPath);
|
|
4215
|
-
|
|
4216
|
-
if (lsRecursive) {
|
|
4217
|
-
// Full tree under path with depth
|
|
4218
|
-
const prefix = parent ? parent + '/' : '';
|
|
4219
|
-
const filtered = !parent ? pages : pages.filter(p => p.path === parent || p.path.startsWith(prefix));
|
|
4220
|
-
const tree = filtered.map(p => ({
|
|
4221
|
-
depth: !parent ? p.path.split('/').length - 1 : p.path.split('/').length - parent.split('/').length - (p.path === parent ? 1 : 0),
|
|
4222
|
-
path: p.path,
|
|
4223
|
-
title: p.title,
|
|
4224
|
-
type: p.type,
|
|
4225
|
-
id: p.id,
|
|
4226
|
-
url: wikiBrowserUrl(p.path),
|
|
4227
|
-
}));
|
|
4228
|
-
return ok({ tree });
|
|
4229
|
-
}
|
|
4230
|
-
|
|
4231
|
-
// Non-recursive: children of parent path
|
|
4232
|
-
if (!parent) {
|
|
4233
|
-
// Root: show top-level directories + root pages
|
|
4234
|
-
const dirs = new Set();
|
|
4235
|
-
const rootPages = [];
|
|
4236
|
-
for (const p of pages) {
|
|
4237
|
-
if (!p.path.includes('/')) { rootPages.push(p); }
|
|
4238
|
-
else { dirs.add(p.path.split('/')[0]); }
|
|
4239
|
-
}
|
|
4240
|
-
return ok({
|
|
4241
|
-
tree: [
|
|
4242
|
-
...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d, url: wikiBrowserUrl(d) })),
|
|
4243
|
-
...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) })),
|
|
4244
|
-
],
|
|
4245
|
-
});
|
|
4246
|
-
}
|
|
4247
|
-
|
|
4248
|
-
// Has parent: show immediate children
|
|
4249
|
-
const children = [];
|
|
4250
|
-
const seenDirs = new Set();
|
|
4251
|
-
const prefix = parent + '/';
|
|
4252
|
-
for (const p of pages) {
|
|
4253
|
-
if (p.path === parent) {
|
|
4254
|
-
children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
|
|
4255
|
-
} else if (p.path.startsWith(prefix)) {
|
|
4256
|
-
const rest = p.path.slice(prefix.length);
|
|
4257
|
-
if (rest.includes('/')) {
|
|
4258
|
-
const sub = rest.split('/')[0];
|
|
4259
|
-
if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub, url: wikiBrowserUrl(parent + '/' + sub) }); }
|
|
4260
|
-
} else {
|
|
4261
|
-
children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
|
|
4262
|
-
}
|
|
4263
|
-
}
|
|
4264
|
-
}
|
|
4265
|
-
return ok({ tree: children });
|
|
4266
|
-
}
|
|
4267
|
-
|
|
4268
|
-
// ── recent ──────────────────────────────────────────────────
|
|
4269
|
-
case 'recent': {
|
|
4270
|
-
const { limit: recentLimit = 10 } = args;
|
|
4271
|
-
const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader);
|
|
4272
|
-
const recent = (tree.pages || [])
|
|
4273
|
-
.filter(p => p.updatedAt)
|
|
4274
|
-
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
|
4275
|
-
.slice(0, Math.max(1, recentLimit))
|
|
4276
|
-
.map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt, url: wikiBrowserUrl(p.path) }));
|
|
4277
|
-
return ok({ pages: recent });
|
|
4278
|
-
}
|
|
4279
|
-
|
|
4280
|
-
// ── read ────────────────────────────────────────────────────
|
|
4281
|
-
// Returns content in hashline format (`LINE+ID|content`) so the
|
|
4282
|
-
// agent can produce hashline edit operations. Mirrors frame.read.
|
|
4283
|
-
case 'read': {
|
|
4284
|
-
const { path: readPath, pageId: readPageId, lines: readLines, raw: readRaw } = args;
|
|
4285
|
-
if (readLines && !/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
|
|
4286
|
-
let page;
|
|
4287
|
-
if (readPageId) {
|
|
4288
|
-
// UUID-first: address the page directly, org auto-derives server-side.
|
|
4289
|
-
const params = new URLSearchParams({ format: 'hashline' });
|
|
4290
|
-
if (readLines) params.set('lines', readLines);
|
|
4291
|
-
if (readRaw) params.set('raw', 'true');
|
|
4292
|
-
page = await api('GET', `/api/wiki/pages/${readPageId}?${params.toString()}`);
|
|
4293
|
-
} else {
|
|
4294
|
-
if (!readPath) throw new Error('path or pageId required for action=read');
|
|
4295
|
-
const normalized = normalizeWikiPath(readPath);
|
|
4296
|
-
const params = new URLSearchParams({ path: normalized, format: 'hashline' });
|
|
4297
|
-
if (readLines) params.set('lines', readLines);
|
|
4298
|
-
if (readRaw) params.set('raw', 'true');
|
|
4299
|
-
page = await api('GET', `/api/wiki/page?${params.toString()}`, undefined, orgHeader);
|
|
4300
|
-
}
|
|
4301
|
-
// Get backlink count via search (approximate)
|
|
4302
|
-
let backlinkCount = 0;
|
|
4303
|
-
try {
|
|
4304
|
-
const searchRes = await api('GET', `/api/wiki/search?q=${encodeURIComponent(page.path)}`, undefined, orgHeader);
|
|
4305
|
-
backlinkCount = (searchRes.hits || []).length;
|
|
4306
|
-
} catch { /* best-effort */ }
|
|
4307
|
-
return ok({
|
|
4308
|
-
id: page.id,
|
|
4309
|
-
path: page.path,
|
|
4310
|
-
title: page.title,
|
|
4311
|
-
type: page.type,
|
|
4312
|
-
frontmatter: page.frontmatter,
|
|
4313
|
-
content: page.content,
|
|
4314
|
-
totalLines: page.totalLines,
|
|
4315
|
-
lastEditedBy: page.updatedBy,
|
|
4316
|
-
lastEditedAt: page.updatedAt,
|
|
4317
|
-
backlinkCount,
|
|
4318
|
-
url: wikiBrowserUrl(page.path, page.orgId),
|
|
4319
|
-
});
|
|
4320
|
-
}
|
|
4321
|
-
|
|
4322
|
-
// ── search ──────────────────────────────────────────────────
|
|
4323
|
-
case 'search': {
|
|
4324
|
-
const { query: searchQuery, limit: searchLimit = 25, org: searchOrg } = args;
|
|
4325
|
-
if (!searchQuery) throw new Error('query required for action=search');
|
|
4326
|
-
// Default: search ALL the caller's orgs (discovery needs no bound org).
|
|
4327
|
-
// An explicit `org` scopes to that one org.
|
|
4328
|
-
const qp = new URLSearchParams({ q: searchQuery, limit: String(searchLimit) });
|
|
4329
|
-
if (!searchOrg) qp.set('scope', 'all');
|
|
4330
|
-
const result = await api('GET', `/api/wiki/search?${qp.toString()}`, undefined, orgHeader);
|
|
4331
|
-
// Surface pageId (re-address by UUID, org-free) + the hit's own org, and
|
|
4332
|
-
// build the URL from the hit's org so cross-org links are correct.
|
|
4333
|
-
const hits = (result.hits || []).map(h => ({
|
|
4334
|
-
id: h.id,
|
|
4335
|
-
path: h.path,
|
|
4336
|
-
title: h.title,
|
|
4337
|
-
org: h.orgName || h.orgSlug || undefined,
|
|
4338
|
-
orgId: h.orgId || undefined,
|
|
4339
|
-
url: wikiPageUrl(h.path, h.orgId || orgId),
|
|
4340
|
-
}));
|
|
4341
|
-
markSearched(getSessionState().gates, 'wiki');
|
|
4342
|
-
return ok({ hits });
|
|
4343
|
-
}
|
|
4344
|
-
|
|
4345
|
-
// ── links ───────────────────────────────────────────────────
|
|
4346
|
-
case 'links': {
|
|
4347
|
-
const { path: linksPath, pageId: linksPageId } = args;
|
|
4348
|
-
let id = linksPageId;
|
|
4349
|
-
if (!id) {
|
|
4350
|
-
if (!linksPath) throw new Error('path or pageId required for action=links');
|
|
4351
|
-
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(linksPath))}`, undefined, orgHeader);
|
|
4352
|
-
id = page.id;
|
|
4353
|
-
}
|
|
4354
|
-
return ok(await api('GET', `/api/wiki/pages/${id}/links`));
|
|
4355
|
-
}
|
|
4356
|
-
|
|
4357
|
-
// ── log ─────────────────────────────────────────────────────
|
|
4358
|
-
// OKF log.md format: newest-first `## YYYY-MM-DD` date headings (UTC),
|
|
4359
|
-
// each with `* **Verb**: message (agent, HH:MM UTC)` bullets. Legacy
|
|
4360
|
-
// `## <ISO datetime> ...` headings on existing log pages are left as-is.
|
|
4361
|
-
case 'log': {
|
|
4362
|
-
const { message: logMessage, verb: logVerb } = args;
|
|
4363
|
-
if (!logMessage) throw new Error('message required for action=log');
|
|
4364
|
-
const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
|
|
4365
|
-
const now = new Date();
|
|
4366
|
-
const entry = formatOkfLogEntry(logVerb, logMessage, agentName, now);
|
|
4367
|
-
const logTitle = (orgCtx?.name ? orgCtx.name + ' ' : '') + 'Log';
|
|
4368
|
-
|
|
4369
|
-
// Try to read existing log page (raw: bytes-as-stored, no synthesis)
|
|
4370
|
-
let logPage = null;
|
|
4371
|
-
try {
|
|
4372
|
-
logPage = await api('GET', '/api/wiki/page?path=log&raw=true', undefined, orgHeader);
|
|
4373
|
-
} catch {
|
|
4374
|
-
// Create new log page
|
|
4375
|
-
const created = await api('POST', '/api/wiki/pages', {
|
|
4376
|
-
path: 'log',
|
|
4377
|
-
title: 'Log',
|
|
4378
|
-
type: 'Log',
|
|
4379
|
-
content: appendOkfLogEntry('', entry, now, logTitle),
|
|
4380
|
-
}, orgHeader);
|
|
4381
|
-
return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
|
|
4382
|
-
}
|
|
4383
|
-
|
|
4384
|
-
// Append under today's date heading (created at the top if missing)
|
|
4385
|
-
const updatedContent = appendOkfLogEntry(logPage.content || '', entry, now, logTitle);
|
|
4386
|
-
await api('PATCH', `/api/wiki/pages/${logPage.id}`, { content: updatedContent }, orgHeader);
|
|
4387
|
-
return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
|
|
4388
|
-
}
|
|
4389
|
-
|
|
4390
|
-
// ── cite ────────────────────────────────────────────────────
|
|
4391
|
-
// Append a numbered citation to a page's `# Citations` section
|
|
4392
|
-
// (creating the section if missing), OKF style: `[n] [label](url)`.
|
|
4393
|
-
// Optionally registers a wiki source when contentHash is given.
|
|
4394
|
-
case 'cite': {
|
|
4395
|
-
const { path: citePath, pageId: citePageId, url: citeUrl, label: citeLabel, contentHash: citeHash, filename: citeFilename } = args;
|
|
4396
|
-
if (!citeUrl) throw new Error('url required for action=cite');
|
|
4397
|
-
let page;
|
|
4398
|
-
if (citePageId) {
|
|
4399
|
-
page = await api('GET', `/api/wiki/pages/${citePageId}?raw=true`);
|
|
4400
|
-
} else {
|
|
4401
|
-
if (!citePath) throw new Error('path or pageId required for action=cite');
|
|
4402
|
-
page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(citePath))}&raw=true`, undefined, orgHeader);
|
|
4403
|
-
}
|
|
4404
|
-
const lines = (page.content || '').split('\n');
|
|
4405
|
-
let maxN = 0;
|
|
4406
|
-
for (const l of lines) {
|
|
4407
|
-
const m = l.match(/^\[(\d+)\]\s/);
|
|
4408
|
-
if (m) maxN = Math.max(maxN, parseInt(m[1], 10));
|
|
4409
|
-
}
|
|
4410
|
-
const n = maxN + 1;
|
|
4411
|
-
const entry = `[${n}] [${citeLabel || citeUrl}](${citeUrl})`;
|
|
4412
|
-
let content;
|
|
4413
|
-
const hIdx = lines.findIndex((l) => /^#{1,3}\s+Citations\s*$/.test(l));
|
|
4414
|
-
if (hIdx < 0) {
|
|
4415
|
-
const base = (page.content || '').replace(/\s+$/, '');
|
|
4416
|
-
content = (base ? base + '\n\n' : '') + '# Citations\n\n' + entry + '\n';
|
|
4417
|
-
} else {
|
|
4418
|
-
let end = hIdx + 1;
|
|
4419
|
-
while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
|
|
4420
|
-
let insertAt = end;
|
|
4421
|
-
while (insertAt > hIdx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
|
|
4422
|
-
lines.splice(insertAt, 0, entry);
|
|
4423
|
-
content = lines.join('\n');
|
|
4424
|
-
}
|
|
4425
|
-
await api('PATCH', `/api/wiki/pages/${page.id}`, { content });
|
|
4426
|
-
let source = null;
|
|
4427
|
-
if (citeHash) {
|
|
4428
|
-
try {
|
|
4429
|
-
source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
|
|
4430
|
-
} catch { /* source registration is best-effort */ }
|
|
4431
|
-
}
|
|
4432
|
-
return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path, page.orgId) }));
|
|
4433
|
-
}
|
|
4434
|
-
|
|
4435
|
-
// ── health ──────────────────────────────────────────────────
|
|
4436
|
-
case 'health': {
|
|
4437
|
-
return ok(await api('GET', '/api/wiki/health', undefined, orgHeader));
|
|
4438
|
-
}
|
|
4439
|
-
|
|
4440
|
-
// ── export ──────────────────────────────────────────────────
|
|
4441
|
-
// The whole wiki as an OKF v0.1 bundle. format="files" pages the bundle
|
|
4442
|
-
// inline; otherwise stdio writes a local dir, remote returns the
|
|
4443
|
-
// authenticated tar.gz download URL.
|
|
4444
|
-
case 'export': {
|
|
4445
|
-
if (args.format === 'files') {
|
|
4446
|
-
const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
|
|
4447
|
-
if (args.offset) qp.set('offset', String(args.offset));
|
|
4448
|
-
if (args.compact) qp.set('compact', 'true');
|
|
4449
|
-
return ok(withOrg(await api('GET', `/api/wiki/export?${qp.toString()}`, undefined, orgHeader)));
|
|
4450
|
-
}
|
|
4451
|
-
if (isRemote) {
|
|
4452
|
-
return ok(withOrg({
|
|
4453
|
-
downloadUrl: `${getServerUrl()}/api/wiki/export.tar.gz`,
|
|
4454
|
-
note: 'Open the URL in a signed-in browser to download the OKF v0.1 bundle, or call export with format="files" to page the bundle contents inline.',
|
|
4455
|
-
}));
|
|
4456
|
-
}
|
|
4457
|
-
// stdio: write every bundle file under a local directory.
|
|
4458
|
-
const orgSlug = String(orgCtx?.name || orgId || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
|
|
4459
|
-
const exportDir = resolve(args.dir || `./okf-export-${orgSlug}`);
|
|
4460
|
-
let expOffset = 0;
|
|
4461
|
-
let written = 0;
|
|
4462
|
-
for (;;) {
|
|
4463
|
-
const batch = await api('GET', `/api/wiki/export?limit=200&offset=${expOffset}`, undefined, orgHeader);
|
|
4464
|
-
const batchFiles = batch.files || [];
|
|
4465
|
-
for (const f of batchFiles) {
|
|
4466
|
-
const dest = resolve(exportDir, f.path);
|
|
4467
|
-
if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
|
|
4468
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
4469
|
-
writeFileSync(dest, f.content, 'utf8');
|
|
4470
|
-
written++;
|
|
4471
|
-
}
|
|
4472
|
-
expOffset += batchFiles.length;
|
|
4473
|
-
if (!batch.truncated || batchFiles.length === 0) break;
|
|
4474
|
-
}
|
|
4475
|
-
return ok(withOrg({ exported: written, dir: exportDir, note: 'OKF v0.1 bundle written. Pages carry synthesized frontmatter; index.md files are synthesized directory listings.' }));
|
|
4476
|
-
}
|
|
4477
|
-
|
|
4478
|
-
// ── import ──────────────────────────────────────────────────
|
|
4479
|
-
// Ingest an OKF bundle: inline files[] or (stdio) a local dir walked
|
|
4480
|
-
// for .md files. index.md skipped, bundle-root log.md merged into the
|
|
4481
|
-
// wiki log page, frontmatter lifted with unknown keys preserved.
|
|
4482
|
-
case 'import': {
|
|
4483
|
-
let importFiles = args.files;
|
|
4484
|
-
if (!importFiles && args.dir) {
|
|
4485
|
-
const root = resolve(args.dir);
|
|
4486
|
-
if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
|
|
4487
|
-
importFiles = [];
|
|
4488
|
-
const walk = (d) => {
|
|
4489
|
-
for (const ent of readdirSync(d, { withFileTypes: true })) {
|
|
4490
|
-
if (ent.name.startsWith('.')) continue;
|
|
4491
|
-
const p = join(d, ent.name);
|
|
4492
|
-
if (ent.isDirectory()) walk(p);
|
|
4493
|
-
else if (/\.md$/i.test(ent.name)) {
|
|
4494
|
-
if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
|
|
4495
|
-
importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
|
|
4496
|
-
}
|
|
4497
|
-
}
|
|
4498
|
-
};
|
|
4499
|
-
walk(root);
|
|
4500
|
-
}
|
|
4501
|
-
if (!Array.isArray(importFiles) || importFiles.length === 0) {
|
|
4502
|
-
throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
|
|
4503
|
-
}
|
|
4504
|
-
const result = await api('POST', '/api/wiki/import', { files: importFiles, dryRun: !!args.dryRun }, orgHeader);
|
|
4505
|
-
return ok(withOrg(result));
|
|
4506
|
-
}
|
|
4507
|
-
|
|
4508
|
-
// ── write ───────────────────────────────────────────────────
|
|
4509
|
-
case 'write': {
|
|
4510
|
-
const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
|
|
4511
|
-
if (!writePath) throw new Error('path required for action=write');
|
|
4512
|
-
if (!writeTitle) throw new Error('title required for action=write');
|
|
4513
|
-
const normalized = normalizeWikiPath(writePath);
|
|
4514
|
-
const body = { path: normalized, title: writeTitle };
|
|
4515
|
-
if (writeContent !== undefined) body.content = writeContent;
|
|
4516
|
-
if (writeType) body.type = writeType;
|
|
4517
|
-
if (frontmatter !== undefined) body.frontmatter = frontmatter;
|
|
4518
|
-
|
|
4519
|
-
// Writing the reserved log page directly is allowed (it IS an editable
|
|
4520
|
-
// page) but the `log` action keeps the OKF date-grouped format for you.
|
|
4521
|
-
const logNote = normalized === 'log'
|
|
4522
|
-
? 'Note: prefer wiki(action="log") for log entries — it maintains the OKF date-grouped format (## YYYY-MM-DD headings, newest first).'
|
|
4523
|
-
: undefined;
|
|
4524
|
-
|
|
4525
|
-
// Check if page exists — if so, update; otherwise create. `orgHeader`
|
|
4526
|
-
// (the `org` arg) targets a specific org without switching the active org.
|
|
4527
|
-
try {
|
|
4528
|
-
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
|
|
4529
|
-
const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
|
|
4530
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
|
|
4531
|
-
} catch {
|
|
4532
|
-
const result = await api('POST', '/api/wiki/pages', body, orgHeader);
|
|
4533
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
|
|
4534
|
-
}
|
|
4535
|
-
}
|
|
4536
|
-
|
|
4537
|
-
// ── edit ────────────────────────────────────────────────────
|
|
4538
|
-
// Hashlines come from `read` (which now formats content as
|
|
4539
|
-
// `LINE+ID|content`). The server applies the ops via the same
|
|
4540
|
-
// hashline algorithm — no client-side re-hashing, no algorithm drift.
|
|
4541
|
-
case 'edit': {
|
|
4542
|
-
const { path: editPath, pageId: editPageId, operations: editOps } = args;
|
|
4543
|
-
if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
|
|
4544
|
-
let id = editPageId;
|
|
4545
|
-
if (!id) {
|
|
4546
|
-
if (!editPath) throw new Error('path or pageId required for action=edit');
|
|
4547
|
-
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(editPath))}`, undefined, orgHeader);
|
|
4548
|
-
id = page.id;
|
|
4549
|
-
}
|
|
4550
|
-
const result = await api('POST', `/api/wiki/pages/${id}/edit`, { operations: editOps });
|
|
4551
|
-
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path, result.orgId) }));
|
|
4552
|
-
}
|
|
4553
|
-
|
|
4554
|
-
// ── mv ──────────────────────────────────────────────────────
|
|
4555
|
-
case 'mv': {
|
|
4556
|
-
const { from: mvFrom, to: mvTo, pageId: mvPageId, dryRun: mvDryRun = false } = args;
|
|
4557
|
-
if (!mvTo) throw new Error('to (destination path) required for action=mv');
|
|
4558
|
-
const toPath = normalizeWikiPath(mvTo);
|
|
4559
|
-
let id = mvPageId;
|
|
4560
|
-
if (!id) {
|
|
4561
|
-
if (!mvFrom) throw new Error('from (source path) or pageId required for action=mv');
|
|
4562
|
-
const fromPath = normalizeWikiPath(mvFrom);
|
|
4563
|
-
if (fromPath === toPath) throw new Error('source and destination are the same');
|
|
4564
|
-
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(fromPath)}`, undefined, orgHeader);
|
|
4565
|
-
id = page.id;
|
|
4566
|
-
}
|
|
4567
|
-
|
|
4568
|
-
if (mvDryRun) {
|
|
4569
|
-
const { referrers } = await api('GET', `/api/wiki/pages/${id}/referrers`);
|
|
4570
|
-
return ok(withOrg({ impacted: referrers.map(r => ({ path: r.path, title: r.title })) }));
|
|
4571
|
-
}
|
|
4572
|
-
|
|
4573
|
-
// Server-side cascade: /move rewrites referrers in one transaction
|
|
4574
|
-
const moved = await api('PATCH', `/api/wiki/pages/${id}/move`, { path: toPath });
|
|
4575
|
-
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path, moved.orgId) }));
|
|
4576
|
-
}
|
|
4577
|
-
|
|
4578
|
-
// ── rm ──────────────────────────────────────────────────────
|
|
4579
|
-
case 'rm': {
|
|
4580
|
-
const { path: rmPath, pageId: rmPageId, dryRun: rmDryRun = false } = args;
|
|
4581
|
-
let id = rmPageId;
|
|
4582
|
-
let normalized = null;
|
|
4583
|
-
if (!id) {
|
|
4584
|
-
if (!rmPath) throw new Error('path or pageId required for action=rm');
|
|
4585
|
-
normalized = normalizeWikiPath(rmPath);
|
|
4586
|
-
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
|
|
4587
|
-
id = page.id;
|
|
4588
|
-
}
|
|
4589
|
-
const { referrers } = await api('GET', `/api/wiki/pages/${id}/referrers`);
|
|
4590
|
-
const broken = referrers.map(r => ({ path: r.path, title: r.title }));
|
|
4591
|
-
|
|
4592
|
-
if (rmDryRun) {
|
|
4593
|
-
return ok(withOrg({ brokenAfterDelete: broken }));
|
|
4594
|
-
}
|
|
4595
|
-
await api('DELETE', `/api/wiki/pages/${id}`);
|
|
4596
|
-
return ok(withOrg({ deleted: true, path: normalized, id, brokenReferences: broken }));
|
|
4597
|
-
}
|
|
4598
|
-
|
|
4599
|
-
// ── bulk-write ──────────────────────────────────────────────
|
|
4600
|
-
// Commit many pages in a single transaction. The MCP wrapper reads
|
|
4601
|
-
// the manifest file locally and posts the pages array inline to
|
|
4602
|
-
// /api/wiki/pages/bulk — avoids paying a tool-call round-trip per
|
|
4603
|
-
// page and keeps the orchestrator's context clean.
|
|
4604
|
-
case 'bulk-write': {
|
|
4605
|
-
const { manifest_path, pages: inlinePages } = args;
|
|
4606
|
-
let pages;
|
|
4607
|
-
if (manifest_path) {
|
|
4608
|
-
if (!existsSync(manifest_path)) throw new Error(`manifest not found: ${manifest_path}`);
|
|
4609
|
-
const text = readFileSync(manifest_path, 'utf8');
|
|
4610
|
-
let parsed;
|
|
4611
|
-
try { parsed = JSON.parse(text); } catch (e) { throw new Error(`invalid JSON in ${manifest_path}: ${e.message}`); }
|
|
4612
|
-
// Accept either {pages: [...]} or a bare array
|
|
4613
|
-
pages = Array.isArray(parsed) ? parsed : parsed.pages;
|
|
4614
|
-
} else if (Array.isArray(inlinePages)) {
|
|
4615
|
-
pages = inlinePages;
|
|
4616
|
-
} else {
|
|
4617
|
-
throw new Error('bulk-write requires either manifest_path or pages array');
|
|
4618
|
-
}
|
|
4619
|
-
if (!Array.isArray(pages) || pages.length === 0) throw new Error('pages array is empty');
|
|
4620
|
-
// Strip surplus fields the server ignores; keep payload tight.
|
|
4621
|
-
const payload = pages.map((p) => ({
|
|
4622
|
-
path: p.path,
|
|
4623
|
-
title: p.title,
|
|
4624
|
-
content: p.content,
|
|
4625
|
-
type: p.type,
|
|
4626
|
-
frontmatter: p.frontmatter,
|
|
4627
|
-
}));
|
|
4628
|
-
const result = await api('POST', '/api/wiki/pages/bulk', { pages: payload }, orgHeader);
|
|
4629
|
-
return ok(withOrg({
|
|
4630
|
-
created: result.created?.length ?? 0,
|
|
4631
|
-
updated: result.updated?.length ?? 0,
|
|
4632
|
-
createdPages: (result.created ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
|
|
4633
|
-
updatedPages: (result.updated ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
|
|
4634
|
-
errors: result.errors ?? [],
|
|
4635
|
-
}));
|
|
4636
|
-
}
|
|
4637
|
-
|
|
4638
|
-
// ── source-register ────────────────────────────────────────
|
|
4639
|
-
// Register an external source (paper, article, transcript) by content
|
|
4640
|
-
// hash. Idempotent: same hash + same org returns the existing source
|
|
4641
|
-
// with `isNew: false` and the pages already derived from it. This is
|
|
4642
|
-
// the dedup boundary — if isNew=false and derivedPages is non-empty,
|
|
4643
|
-
// the agent should extend those pages, not create new ones.
|
|
4644
|
-
case 'source-register': {
|
|
4645
|
-
const { file_path: srcFile, contentHash, filename, contentType, size } = args;
|
|
4646
|
-
if (!srcFile && !contentHash) throw new Error('file_path or contentHash required for action=source-register');
|
|
4647
|
-
const body = {};
|
|
4648
|
-
if (contentHash) body.contentHash = contentHash;
|
|
4649
|
-
if (filename) body.filename = filename;
|
|
4650
|
-
if (contentType) body.contentType = contentType;
|
|
4651
|
-
if (size !== undefined) body.size = size;
|
|
4652
|
-
if (srcFile && !contentHash) {
|
|
4653
|
-
// Hash locally — server may not be able to read this client's filesystem.
|
|
4654
|
-
if (!existsSync(srcFile)) throw new Error(`file not found: ${srcFile}`);
|
|
4655
|
-
const buf = readFileSync(srcFile);
|
|
4656
|
-
body.contentHash = createHash('sha256').update(buf).digest('hex');
|
|
4657
|
-
body.size = body.size ?? buf.byteLength;
|
|
4658
|
-
body.filename = body.filename ?? basename(srcFile);
|
|
4659
|
-
}
|
|
4660
|
-
const result = await api('POST', '/api/wiki/sources', body);
|
|
4661
|
-
return ok(withOrg(result));
|
|
4662
|
-
}
|
|
4663
|
-
|
|
4664
|
-
// ── source-list ────────────────────────────────────────────
|
|
4665
|
-
case 'source-list': {
|
|
4666
|
-
const { contentHash, limit: srcLimit = 50 } = args;
|
|
4667
|
-
if (contentHash) {
|
|
4668
|
-
try {
|
|
4669
|
-
const result = await api('GET', `/api/wiki/sources?hash=${encodeURIComponent(contentHash)}`);
|
|
4670
|
-
return ok({ sources: [result] });
|
|
4671
|
-
} catch (e) {
|
|
4672
|
-
if (/Not found/.test(e.message)) return ok({ sources: [] });
|
|
4673
|
-
throw e;
|
|
4674
|
-
}
|
|
4675
|
-
}
|
|
4676
|
-
const result = await api('GET', `/api/wiki/sources?limit=${srcLimit}`);
|
|
4677
|
-
return ok(result);
|
|
4678
|
-
}
|
|
4679
|
-
|
|
4680
|
-
// ── source-get ─────────────────────────────────────────────
|
|
4681
|
-
case 'source-get': {
|
|
4682
|
-
const { sourceId } = args;
|
|
4683
|
-
if (!sourceId) throw new Error('sourceId required for action=source-get');
|
|
4684
|
-
const result = await api('GET', `/api/wiki/sources/${encodeURIComponent(sourceId)}`);
|
|
4685
|
-
return ok(result);
|
|
4686
|
-
}
|
|
4687
|
-
|
|
4688
|
-
default:
|
|
4689
|
-
throw new Error(`Unknown wiki action: ${action}`);
|
|
4690
|
-
}
|
|
4691
|
-
} catch (error) { return err(error); }
|
|
4692
|
-
});
|
|
4693
|
-
|
|
4694
|
-
// ── Minions ───────────────────────────────────────────────────────
|
|
4695
|
-
|
|
4696
|
-
function compactMinionEntry(c) {
|
|
4697
|
-
if (!c || typeof c !== 'object') return c;
|
|
4698
|
-
return { id: c.id, slug: c.slug, name: c.name, enabled: c.enabled, projectId: c.projectId };
|
|
4699
|
-
}
|
|
4700
|
-
|
|
4701
|
-
// Shape a {minions:[...]} list with limit/offset pagination + optional compact
|
|
4702
|
-
// mode, mirroring shapeSkillCatalog so large lists stay within token budget.
|
|
4703
|
-
function shapeMinionList(result, { limit, offset = 0, compact = false } = {}) {
|
|
4704
|
-
if (!Array.isArray(result?.minions)) return result;
|
|
4705
|
-
const total = result.minions.length;
|
|
4706
|
-
const start = Math.max(0, Math.floor(Number(offset) || 0));
|
|
4707
|
-
const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
|
|
4708
|
-
const page = result.minions.slice(start, start + cap);
|
|
4709
|
-
result.totalAvailable = total;
|
|
4710
|
-
result.offset = start;
|
|
4711
|
-
result.returned = page.length;
|
|
4712
|
-
result.truncated = start + page.length < total;
|
|
4713
|
-
result.minions = compact ? page.map(compactMinionEntry) : page;
|
|
4714
|
-
result.note = compact
|
|
4715
|
-
? 'Compact list: {id,slug,name,enabled,projectId} only. Use minion(action="get", id="<id>") for full config; limit/offset to page.'
|
|
4716
|
-
: 'Minions are scoped to the active project (all org Minions when no project is open). Use limit/offset to page; compact=true for a leaner list.';
|
|
4717
|
-
return result;
|
|
4718
|
-
}
|
|
4719
|
-
|
|
4720
|
-
// Friendlier message when the agent allowlist gate (requireAgentAccess) rejects.
|
|
4721
|
-
function minionGateError(e) {
|
|
4722
|
-
if (e?.status === 403 || e?.code === 'agent_disabled') {
|
|
4723
|
-
return new Error('Minion management is not enabled for this org/account (agent allowlist). Ask an admin to add your org or email to DRAFTED_AGENT_ALLOWED_ORGS / DRAFTED_AGENT_ALLOWED_EMAILS.');
|
|
4724
|
-
}
|
|
4725
|
-
return e;
|
|
4726
|
-
}
|
|
4727
2415
|
|
|
4728
2416
|
tool('minion', {
|
|
4729
2417
|
action: z.enum(['meta', 'list', 'get', 'create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve']).describe('Operation to perform. test_* drive a QA conversation against a Minion you own (even disabled) to verify it end-to-end.'),
|
|
@@ -4732,7 +2420,7 @@ tool('minion', {
|
|
|
4732
2420
|
fresh: z.boolean().optional().describe('[test_start] start a brand-new run instead of resuming your latest'),
|
|
4733
2421
|
actionId: z.string().optional().describe('[test_resolve] id of the pending action to resolve'),
|
|
4734
2422
|
approve: z.boolean().optional().describe('[test_resolve] approve (default true) or reject the pending action'),
|
|
4735
|
-
projectId: z.string().optional().describe('[create
|
|
2423
|
+
projectId: z.string().optional().describe('[meta|list|create] the project this Minion belongs to — REQUIRED, as a path (/projects/<name> or /projects/<folder>/<name>), slug, or UUID. Minions are project-scoped: they write producibles into that project\'s surface. No session fallback — name the project.'),
|
|
4736
2424
|
name: z.string().optional().describe('[create|update] Minion name'),
|
|
4737
2425
|
description: z.string().optional().describe('[create|update] one-line description shown to the consumer'),
|
|
4738
2426
|
enabled: z.boolean().optional().describe('[create|update] whether the Minion is live; a disabled Minion 404s on its /c/<slug> link. enable/disable set this directly.'),
|
|
@@ -4768,20 +2456,21 @@ tool('minion', {
|
|
|
4768
2456
|
}, async (args) => {
|
|
4769
2457
|
try {
|
|
4770
2458
|
const { action } = args;
|
|
2459
|
+
// Minions are project-scoped: meta/list/create need the project, addressed
|
|
2460
|
+
// by fs path (or slug/UUID). Resolved once, scoped via withProjectOverride so
|
|
2461
|
+
// api() appends THIS project (not whatever session is bound), echoed back as projectPath.
|
|
2462
|
+
const projectNeeded = action === 'meta' || action === 'list' || action === 'create';
|
|
2463
|
+
const project = projectNeeded ? await resolveProjectArg(args.projectId) : null;
|
|
2464
|
+
const projectPath = project ? `/projects/${project.slug}` : null;
|
|
2465
|
+
const scoped = (fn) => project ? withProjectOverride(project, fn) : fn();
|
|
4771
2466
|
switch (action) {
|
|
4772
2467
|
case 'meta': {
|
|
4773
|
-
|
|
4774
|
-
const pid = active || args.projectId;
|
|
4775
|
-
if (!pid) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
|
|
4776
|
-
// api() auto-appends the active project; add projectId explicitly only when none is active.
|
|
4777
|
-
const path = active ? '/api/minions/meta' : `/api/minions/meta?projectId=${encodeURIComponent(pid)}`;
|
|
4778
|
-
return ok(await api('GET', path));
|
|
2468
|
+
return ok(await scoped(() => api('GET', '/api/minions/meta').then(r => ({ ...r, projectPath }))));
|
|
4779
2469
|
}
|
|
4780
2470
|
case 'list': {
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
return ok(shapeMinionList(result, { limit: args.limit, offset: args.offset, compact: args.compact }));
|
|
2471
|
+
const result = await scoped(() => api('GET', '/api/minions'));
|
|
2472
|
+
const minions = (result.minions || []).map(m => ({ ...m, projectPath }));
|
|
2473
|
+
return ok({ ...result, minions, projectPath });
|
|
4785
2474
|
}
|
|
4786
2475
|
case 'get': {
|
|
4787
2476
|
if (!args.id) throw new Error('id is required for action=get');
|
|
@@ -4789,16 +2478,15 @@ tool('minion', {
|
|
|
4789
2478
|
}
|
|
4790
2479
|
case 'create': {
|
|
4791
2480
|
// projectId lives in the BODY (the POST route reads body, ignores the query).
|
|
4792
|
-
const projectId = args.projectId || getState().projectId;
|
|
4793
|
-
if (!projectId) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
|
|
4794
2481
|
const { name, description, target, checklist, output, enabled } = args;
|
|
4795
2482
|
if (!name || !target || !Array.isArray(checklist) || !output) {
|
|
4796
2483
|
throw new Error('name, target, checklist[], and output are required for action=create');
|
|
4797
2484
|
}
|
|
4798
|
-
const body = { projectId, name, target, checklist, output };
|
|
2485
|
+
const body = { projectId: project.id, name, target, checklist, output };
|
|
4799
2486
|
if (description !== undefined) body.description = description;
|
|
4800
2487
|
if (enabled !== undefined) body.enabled = enabled;
|
|
4801
|
-
|
|
2488
|
+
const created = await scoped(() => api('POST', '/api/minions', body));
|
|
2489
|
+
return ok({ ...created, projectPath });
|
|
4802
2490
|
}
|
|
4803
2491
|
case 'update': {
|
|
4804
2492
|
if (!args.id) throw new Error('id is required for action=update');
|
|
@@ -4835,7 +2523,7 @@ tool('minion', {
|
|
|
4835
2523
|
default:
|
|
4836
2524
|
throw new Error(`Unknown minion action: ${action}`);
|
|
4837
2525
|
}
|
|
4838
|
-
} catch (error) { return err(
|
|
2526
|
+
} catch (error) { return err(error); }
|
|
4839
2527
|
});
|
|
4840
2528
|
|
|
4841
2529
|
// ── Resource: canvas info ─────────────────────────────────────────
|
|
@@ -4851,13 +2539,583 @@ server.resource('info', 'drafted://info', {
|
|
|
4851
2539
|
text: JSON.stringify({
|
|
4852
2540
|
version: PACKAGE_VERSION,
|
|
4853
2541
|
layers: LAYERS,
|
|
4854
|
-
pathFormat: '/
|
|
4855
|
-
|
|
2542
|
+
pathFormat: '/o/<org>/<root>/<path> — org first, no org switching',
|
|
2543
|
+
roots: {
|
|
2544
|
+
wiki: '/o/<org>/wiki/<path>',
|
|
2545
|
+
skills: '/o/<org>/skills/<slug>',
|
|
2546
|
+
projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
|
|
2547
|
+
},
|
|
2548
|
+
tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'screenshot', 'minion'],
|
|
4856
2549
|
}, null, 2),
|
|
4857
2550
|
}],
|
|
4858
2551
|
};
|
|
4859
2552
|
});
|
|
4860
2553
|
|
|
2554
|
+
tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/projects` roots still resolve via the session\'s working org.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (across wiki + skills + projects). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
|
|
2555
|
+
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search']).describe('Filesystem verb.'),
|
|
2556
|
+
path: z.string().describe('Drafted path: /o/<org>/wiki/... | /o/<org>/skills/... | /o/<org>/projects/... (bare /wiki, /skills, /projects also work; for mv: source)'),
|
|
2557
|
+
to: z.string().optional().describe('[mv] destination path'),
|
|
2558
|
+
query: z.string().optional().describe('[search] term to match against names/content'),
|
|
2559
|
+
content: z.string().optional().describe('[write] inline HTML/markdown/text'),
|
|
2560
|
+
file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only)'),
|
|
2561
|
+
base64: z.string().optional().describe('[write] base64-encoded binary content'),
|
|
2562
|
+
googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] explicit Google Workspace type (also derived from .google-* filename extension)'),
|
|
2563
|
+
title: z.string().optional().describe('[write + googleType] title for a new native file'),
|
|
2564
|
+
ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames ({type,lineHash,newContent}), element ops for excalidraw ({id,x,y,...}), or office ops'),
|
|
2565
|
+
state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
|
|
2566
|
+
recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
|
|
2567
|
+
lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
|
|
2568
|
+
pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
|
|
2569
|
+
org: z.string().optional().describe('Org (id or name) for bare /wiki and /skills paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
|
|
2570
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
2571
|
+
}, async (args) => {
|
|
2572
|
+
const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, recursive, pattern, lines, org } = args;
|
|
2573
|
+
// Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
|
|
2574
|
+
// carry it as the per-request org scope (orgHeader), validate per-root below.
|
|
2575
|
+
// The PATH is the address; the org= param is legacy for bare roots only, and a
|
|
2576
|
+
// contradiction between the two is an error, never a silent winner. A full share
|
|
2577
|
+
// URL (https://…/o/…) is stripped to its pathname first — the pathname IS the fs path.
|
|
2578
|
+
const scoped = splitOrgScope(stripUrlOrigin(rawPath));
|
|
2579
|
+
if (scoped.error) return err(new Error(scoped.error));
|
|
2580
|
+
let p = scoped.path;
|
|
2581
|
+
const orgFromPath = scoped.org;
|
|
2582
|
+
if (org && orgFromPath && String(org).toLowerCase() !== String(orgFromPath).toLowerCase()) {
|
|
2583
|
+
return err(new Error(`org="${org}" contradicts the org addressed by the path (/o/${orgFromPath}/) — address the org in the path only`));
|
|
2584
|
+
}
|
|
2585
|
+
let to = rawTo;
|
|
2586
|
+
if (to) {
|
|
2587
|
+
const toScoped = splitOrgScope(stripUrlOrigin(to));
|
|
2588
|
+
if (toScoped.error) return err(new Error(toScoped.error));
|
|
2589
|
+
if (toScoped.org && orgFromPath && toScoped.org.toLowerCase() !== orgFromPath.toLowerCase()) {
|
|
2590
|
+
return err(new Error('mv stays within one org — source and destination must address the same org'));
|
|
2591
|
+
}
|
|
2592
|
+
if (toScoped.org && !orgFromPath) {
|
|
2593
|
+
return err(new Error('mv to an org-scoped destination needs an org-scoped source too (/o/<org>/... on both sides)'));
|
|
2594
|
+
}
|
|
2595
|
+
to = toScoped.path;
|
|
2596
|
+
}
|
|
2597
|
+
const orgHeader = org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {});
|
|
2598
|
+
const gs = getSessionState().gates;
|
|
2599
|
+
|
|
2600
|
+
// ── Root listing: / or empty → the orgs this session can address (Shape A) ──
|
|
2601
|
+
if (!p || p === '/' || p === '') {
|
|
2602
|
+
if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, or /o/<org>/projects'));
|
|
2603
|
+
if (orgFromPath) {
|
|
2604
|
+
// ls /o/<org> → that org's three roots
|
|
2605
|
+
return ok([
|
|
2606
|
+
{ name: 'wiki', type: 'directory', path: `/o/${orgFromPath}/wiki`, hint: 'org knowledge pages (markdown, OKF)' },
|
|
2607
|
+
{ name: 'skills', type: 'directory', path: `/o/${orgFromPath}/skills`, hint: 'reusable procedures (flat: one dir per skill slug)' },
|
|
2608
|
+
{ name: 'projects', type: 'directory', path: `/o/${orgFromPath}/projects`, hint: '<folder?>/<project>/<layer>/<lane>/<file>' },
|
|
2609
|
+
]);
|
|
2610
|
+
}
|
|
2611
|
+
// ls / → the orgs (id, slug, name) the agent belongs to; org is the top folder
|
|
2612
|
+
const orgs = await getOrgList();
|
|
2613
|
+
if (!orgs.length) return err(new Error('no orgs to list — are you signed in? fs(ls, path="/") shows the orgs you belong to'));
|
|
2614
|
+
return ok(orgs.map(o => ({ name: o.name || o.id, type: 'directory', path: `/o/${o.slug || o.id}`, hint: 'org — fs(ls, path="/o/<org>") shows its wiki/skills/projects' })));
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// ── mkdir: create a project (a directory in the /projects root) ──
|
|
2618
|
+
if (action === 'mkdir') {
|
|
2619
|
+
if (!p.startsWith('/projects')) {
|
|
2620
|
+
return err(new Error('mkdir is only meaningful under /projects (wiki dirs are implicit; skills are flat) — e.g. fs(mkdir, path="/o/<org>/projects/my-project")'));
|
|
2621
|
+
}
|
|
2622
|
+
// A project create is project-less: it must not guess the org.
|
|
2623
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
2624
|
+
const parts = p.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
|
|
2625
|
+
if (parts.length === 0 || parts.length > 2) {
|
|
2626
|
+
return err(new Error(`mkdir path must be /projects/<name> or /projects/<folder>/<name> — got ${p}`));
|
|
2627
|
+
}
|
|
2628
|
+
const folder = parts.length === 2 ? parts[0] : null;
|
|
2629
|
+
const projectRef = parts[parts.length - 1];
|
|
2630
|
+
const existing = await resolveProjectRef(projectRef).catch(() => null);
|
|
2631
|
+
if (existing) return err(new Error(`project already exists: ${projectRef}`));
|
|
2632
|
+
const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2633
|
+
const created = await api('POST', '/api/projects', { name: projectName }, orgHeader);
|
|
2634
|
+
if (!created?.id) return err(new Error('project create returned no id'));
|
|
2635
|
+
if (folder) {
|
|
2636
|
+
try { await api('PATCH', `/api/project/${created.id}`, { folder }, orgHeader); } catch { /* folder best-effort */ }
|
|
2637
|
+
}
|
|
2638
|
+
projectRefCache.set(projectRef, { id: created.id, slug: created.slug, name: created.name, orgId: created.orgId, orgSlug: created.orgSlug });
|
|
2639
|
+
return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${orgFromPath ? `/o/${orgFromPath}` : ''}/projects${folder ? '/' + folder : ''}/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// ── Root: /wiki/... ────────────────────────────────────────────
|
|
2643
|
+
if (p.startsWith('/wiki')) {
|
|
2644
|
+
const wikiPath = p === '/wiki' || p === '/wiki/' ? '' : p.replace(/^\/wiki\/?/, '');
|
|
2645
|
+
// Project-less mutations must not guess the org (Shape A: address it in the path —
|
|
2646
|
+
// an org-scoped path or org= counts as explicit; a bare path needs a bound org).
|
|
2647
|
+
if (['write', 'edit', 'mv', 'rm'].includes(action)) {
|
|
2648
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
2649
|
+
}
|
|
2650
|
+
switch (action) {
|
|
2651
|
+
case 'ls': {
|
|
2652
|
+
if (!wikiPath) {
|
|
2653
|
+
const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader);
|
|
2654
|
+
return ok(formatWikiIndex(tree?.pages || []));
|
|
2655
|
+
}
|
|
2656
|
+
const pages = await api('GET', `/api/wiki/tree?prefix=${encodeURIComponent(wikiPath)}`, undefined, orgHeader);
|
|
2657
|
+
return ok(formatWikiIndex(pages?.pages || []));
|
|
2658
|
+
}
|
|
2659
|
+
case 'read': {
|
|
2660
|
+
const canonPath = wikiPath.replace(/\.md$/, '');
|
|
2661
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}&raw=true`, undefined, orgHeader);
|
|
2662
|
+
if (!page?.content) return err(new Error(`wiki page not found: ${canonPath}`));
|
|
2663
|
+
return ok(page.content);
|
|
2664
|
+
}
|
|
2665
|
+
case 'write': {
|
|
2666
|
+
if (!content) return err(new Error('write to /wiki/... requires content'));
|
|
2667
|
+
// OKF boundary rule: /a/b.md ≡ a/b — strip a trailing .md so both spellings work.
|
|
2668
|
+
const canonPath = wikiPath.replace(/\.md$/, '');
|
|
2669
|
+
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
|
|
2670
|
+
const body = { path: canonPath, title: canonPath.split('/').pop(), content, type: existing?.type || 'Page' };
|
|
2671
|
+
const result = existing
|
|
2672
|
+
? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content }, orgHeader)
|
|
2673
|
+
: await api('POST', '/api/wiki/pages', body, orgHeader);
|
|
2674
|
+
return ok(result || { path: canonPath, written: true });
|
|
2675
|
+
}
|
|
2676
|
+
case 'edit': {
|
|
2677
|
+
if (!ops?.length) return err(new Error('edit requires ops'));
|
|
2678
|
+
const result = await api('POST', '/api/wiki/edit', { path: wikiPath, operations: ops }, orgHeader);
|
|
2679
|
+
return ok(result);
|
|
2680
|
+
}
|
|
2681
|
+
case 'mv': {
|
|
2682
|
+
if (!to || !to.startsWith('/wiki')) return err(new Error('mv within /wiki requires to=/wiki/...'));
|
|
2683
|
+
const result = await api('POST', '/api/wiki/mv', { from: wikiPath, to: to.replace(/^\/wiki\/?/, '') }, orgHeader);
|
|
2684
|
+
return ok(result);
|
|
2685
|
+
}
|
|
2686
|
+
case 'rm': {
|
|
2687
|
+
// Soft delete: move the page under archive/ (agents never hard-delete;
|
|
2688
|
+
// the web UI offers permanent delete in the archive). mv cascades
|
|
2689
|
+
// inbound refs, so links keep working into the archive.
|
|
2690
|
+
const canonPath = wikiPath.replace(/\.md$/, '');
|
|
2691
|
+
const archivePath = canonPath.startsWith('archive/') ? canonPath : `archive/${canonPath}`;
|
|
2692
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
|
|
2693
|
+
if (!page?.id) return err(new Error(`wiki page not found: ${canonPath}`));
|
|
2694
|
+
const result = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: archivePath }, orgHeader);
|
|
2695
|
+
return ok({ archived: true, from: canonPath, to: archivePath, ...(result?.referrersUpdated ? { referrersUpdated: result.referrersUpdated } : {}) });
|
|
2696
|
+
}
|
|
2697
|
+
case 'search': {
|
|
2698
|
+
markSearched(gs, 'wiki');
|
|
2699
|
+
const pages = await api('GET', `/api/wiki/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
|
|
2700
|
+
return ok(formatWikiIndex(pages?.pages || pages?.results || []));
|
|
2701
|
+
}
|
|
2702
|
+
default:
|
|
2703
|
+
return err(new Error(`fs ${action} not supported for /wiki`));
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// ── Root: /skills/... ──────────────────────────────────────────
|
|
2708
|
+
if (p.startsWith('/skills')) {
|
|
2709
|
+
const slug = p === '/skills' || p === '/skills/' ? '' : p.replace(/^\/skills\/?/, '').split('/')[0];
|
|
2710
|
+
if (['write', 'mv', 'rm'].includes(action)) {
|
|
2711
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
2712
|
+
}
|
|
2713
|
+
switch (action) {
|
|
2714
|
+
case 'ls': {
|
|
2715
|
+
const list = await api('GET', '/api/skills', undefined, orgHeader);
|
|
2716
|
+
const skills = Array.isArray(list) ? list : (list?.skills || []);
|
|
2717
|
+
return ok(skills.map(s => ({ slug: s.slug, name: s.name, description: s.description })));
|
|
2718
|
+
}
|
|
2719
|
+
case 'read': {
|
|
2720
|
+
if (!slug) return err(new Error('read /skills/<slug>'));
|
|
2721
|
+
const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
|
|
2722
|
+
return ok(s?.content || '');
|
|
2723
|
+
}
|
|
2724
|
+
case 'write': {
|
|
2725
|
+
if (!slug || !content) return err(new Error('write /skills/<slug> requires content'));
|
|
2726
|
+
const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2727
|
+
if (!existing) {
|
|
2728
|
+
const g2 = g2Block(gs);
|
|
2729
|
+
if (g2) return err(new Error(g2));
|
|
2730
|
+
}
|
|
2731
|
+
const name = existing?.name || slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2732
|
+
// Derive a description from the slug when the agent didn't pass one
|
|
2733
|
+
// (the API requires a non-empty description on create).
|
|
2734
|
+
const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
|
|
2735
|
+
const result = existing
|
|
2736
|
+
? await api('PUT', `/api/skills/${existing.id}`, { content }, orgHeader)
|
|
2737
|
+
: await api('POST', '/api/skills', { slug, name, content, description }, orgHeader);
|
|
2738
|
+
// Writing an archived skill restores it (update flow un-archives).
|
|
2739
|
+
if (result?.id && existing?.archived) {
|
|
2740
|
+
try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
|
|
2741
|
+
}
|
|
2742
|
+
return ok(result || { slug, written: true });
|
|
2743
|
+
}
|
|
2744
|
+
case 'mv': {
|
|
2745
|
+
if (!to || !to.startsWith('/skills')) return err(new Error('mv within /skills requires to=/skills/...'));
|
|
2746
|
+
const toSlug = to.replace(/^\/skills\/?/, '').split('/')[0];
|
|
2747
|
+
const result = await api('POST', '/api/skills/fork', { from: slug, to: toSlug, ...(org ? { org } : {}) }, orgHeader);
|
|
2748
|
+
return ok(result);
|
|
2749
|
+
}
|
|
2750
|
+
case 'rm': {
|
|
2751
|
+
const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
|
|
2752
|
+
if (!s?.id) return err(new Error(`skill not found: ${slug}`));
|
|
2753
|
+
return ok(await api('DELETE', `/api/skills/${s.id}`, undefined, orgHeader));
|
|
2754
|
+
}
|
|
2755
|
+
case 'search': {
|
|
2756
|
+
markSearched(gs, 'skill');
|
|
2757
|
+
const results = await api('GET', `/api/skills/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
|
|
2758
|
+
const skills = Array.isArray(results) ? results : (results?.skills || []);
|
|
2759
|
+
return ok(skills.map(s => ({ slug: s.slug, name: s.name, description: s.description })));
|
|
2760
|
+
}
|
|
2761
|
+
default:
|
|
2762
|
+
return err(new Error(`fs ${action} not supported for /skills`));
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// ── Root: /projects/... ────────────────────────────────────────
|
|
2767
|
+
if (p.startsWith('/projects')) {
|
|
2768
|
+
const parts = p.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
|
|
2769
|
+
// Resolve the project from the path: <folder?>/<project>/<layer>/<lane>/<file>
|
|
2770
|
+
let projectRef = null, layer, lane, filename;
|
|
2771
|
+
if (parts.length === 0) {
|
|
2772
|
+
// /projects or /o/<org>/projects — list projects (archived ones live in the
|
|
2773
|
+
// Archive bin and are hidden from the agent's active list — mirroring the web UI).
|
|
2774
|
+
// An org-scoped root lists ONLY that org's projects (Shape A).
|
|
2775
|
+
const projects = await api('GET', '/api/projects');
|
|
2776
|
+
const active = Array.isArray(projects?.projects)
|
|
2777
|
+
? { ...projects, projects: projects.projects.filter(x => x.folder !== '__archived') }
|
|
2778
|
+
: projects;
|
|
2779
|
+
if (orgFromPath && Array.isArray(active?.projects)) {
|
|
2780
|
+
const kept = [];
|
|
2781
|
+
for (const x of active.projects) {
|
|
2782
|
+
if (await pathOrgMatches(orgFromPath, x)) kept.push(x);
|
|
2783
|
+
}
|
|
2784
|
+
active.projects = kept;
|
|
2785
|
+
}
|
|
2786
|
+
return ok(active);
|
|
2787
|
+
}
|
|
2788
|
+
if (parts.length >= 4) {
|
|
2789
|
+
projectRef = parts.length === 4 ? parts[0] : parts[1]; // no-folder vs folder form
|
|
2790
|
+
layer = parts[parts.length - 3];
|
|
2791
|
+
lane = parts[parts.length - 2];
|
|
2792
|
+
filename = parts[parts.length - 1];
|
|
2793
|
+
} else if (parts.length === 3) {
|
|
2794
|
+
// /projects/<project>/<layer>/<lane> OR /projects/<project>/<layer>/<file>
|
|
2795
|
+
// A lane view (no extension) vs a layer-root file (has an extension) —
|
|
2796
|
+
// mirror the server's dual routes.
|
|
2797
|
+
projectRef = parts[0];
|
|
2798
|
+
const last = parts[2];
|
|
2799
|
+
if (/\.[a-z0-9]+$/i.test(last) && !['designs', 'research', 'plans', 'copy', 'wireframes', 'images', 'components', 'brand-assets'].includes(last)) {
|
|
2800
|
+
layer = parts[1]; lane = null; filename = last; // layer-root file
|
|
2801
|
+
} else {
|
|
2802
|
+
layer = parts[1]; lane = last; filename = null; // lane view
|
|
2803
|
+
}
|
|
2804
|
+
} else if (parts.length === 2) {
|
|
2805
|
+
projectRef = parts[0]; // /projects/<project>/<layer>
|
|
2806
|
+
layer = parts[1]; lane = null; filename = null;
|
|
2807
|
+
} else if (parts.length === 1) {
|
|
2808
|
+
projectRef = parts[0]; // /projects/<project>
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const run = async () => {
|
|
2812
|
+
const hasFile = layer && filename;
|
|
2813
|
+
// Layer-root file (lane null) hits the server's 2-segment route.
|
|
2814
|
+
const filePath = hasFile ? (lane ? `${encodeURIComponent(layer)}/${encodeURIComponent(lane)}/${encodeURIComponent(filename)}` : `${encodeURIComponent(layer)}/${encodeURIComponent(filename)}`) : null;
|
|
2815
|
+
const g1Mutating = ['write', 'edit', 'mv', 'rm'].includes(action);
|
|
2816
|
+
|
|
2817
|
+
if (g1Mutating && !gs.wikiSearched) {
|
|
2818
|
+
let wikiIndex = '';
|
|
2819
|
+
try { const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader); wikiIndex = formatWikiIndex(tree?.pages || []); } catch { /* best-effort */ }
|
|
2820
|
+
return err(new Error(g1Block(gs, wikiIndex)));
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
switch (action) {
|
|
2824
|
+
case 'ls': {
|
|
2825
|
+
// Listing path relative to the project. No-folder forms: [p] → '/',
|
|
2826
|
+
// [p, layer] → '/layer', [p, layer, lane] → '/layer/lane' — a lane/layer
|
|
2827
|
+
// URL IS this path (Q2), so ls of a shared lane URL lists the lane.
|
|
2828
|
+
// (Folder-form ls — [folder, p, layer[, lane]] — is a pre-existing dead
|
|
2829
|
+
// end: the parse takes parts[0] as the project ref and resolve fails.)
|
|
2830
|
+
let lsPath, lsProjectId;
|
|
2831
|
+
if (projectRef && parts.length >= 3) { lsProjectId = projectRef; lsPath = '/' + parts.slice(parts.length - 2).join('/'); }
|
|
2832
|
+
else if (projectRef && parts.length === 2) { lsProjectId = projectRef; lsPath = '/' + parts[1]; }
|
|
2833
|
+
else { lsProjectId = projectRef; lsPath = '/'; }
|
|
2834
|
+
const lsParams = new URLSearchParams({ path: lsPath });
|
|
2835
|
+
if (recursive) { lsParams.set('recursive', 'true'); lsParams.set('summary', 'true'); }
|
|
2836
|
+
if (pattern) lsParams.set('pattern', pattern);
|
|
2837
|
+
const result = await api('GET', `/api/fs/?${lsParams.toString()}`, undefined, orgHeader);
|
|
2838
|
+
return ok(result);
|
|
2839
|
+
}
|
|
2840
|
+
case 'read': {
|
|
2841
|
+
if (!filePath) return err(new Error('read requires a full file path: /projects/<project>/<layer>/<lane>/<file>'));
|
|
2842
|
+
const query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
|
|
2843
|
+
const result = await api('GET', `/api/fs/${filePath}${query}`, undefined, orgHeader);
|
|
2844
|
+
if (result?.type === 'binary') return ok({ type: 'binary', path: result.path, contentType: result.contentType });
|
|
2845
|
+
// App frames (drafted:frame-type=app) carry a separate hydration state —
|
|
2846
|
+
// return the state + a content preview rather than the full bundle, which
|
|
2847
|
+
// is usually a large minified JS blob that would blow the tool-result cap.
|
|
2848
|
+
if (result?.id && /drafted:frame-type[^>]*content="app"/.test(result.content || '')) {
|
|
2849
|
+
let state = null;
|
|
2850
|
+
try { const st = await api('GET', `/api/file/${result.id}/state`); state = st?.state ?? null; } catch { /* best-effort */ }
|
|
2851
|
+
return ok({ appFrame: true, frameId: result.id, frameUrl: `${getServerUrl()}/f/${result.id}`, state, contentPreview: (result.content || '').slice(0, 500) });
|
|
2852
|
+
}
|
|
2853
|
+
// Surface the clickable link alongside content so the agent can hand
|
|
2854
|
+
// the user a URL for what it just read (filesystem parity: cat gives
|
|
2855
|
+
// you the path; this gives the path AND the shareable link). Prefer the
|
|
2856
|
+
// semantic /o/<org>/<project>/<path> form when the project context is
|
|
2857
|
+
// available; /f/<uuid> is the always-works fallback.
|
|
2858
|
+
if (result?.id) {
|
|
2859
|
+
const proj = result.project;
|
|
2860
|
+
const semantic = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
|
|
2861
|
+
return ok({ path: result.path, frameUrl: semantic || result.frameUrl || `${getServerUrl()}/f/${result.id}`, content: result.content || '' });
|
|
2862
|
+
}
|
|
2863
|
+
return ok(result?.content || '');
|
|
2864
|
+
}
|
|
2865
|
+
case 'write': {
|
|
2866
|
+
if (!filePath) return err(new Error('write requires a full file path: /projects/<project>/<layer>/<lane>/<file>'));
|
|
2867
|
+
// Google Workspace: explicit googleType OR .google-* filename extension.
|
|
2868
|
+
const extType = /\.google-(doc|sheet|slide)$/.exec(filename || '');
|
|
2869
|
+
const gw = googleType || (extType ? 'google-' + extType[1] : null);
|
|
2870
|
+
// App-frame state write (persist state only, no content change).
|
|
2871
|
+
if (state !== undefined && !content && !file_path && !base64 && !gw) {
|
|
2872
|
+
const f = await api('GET', `/api/fs/${filePath}`, undefined, orgHeader);
|
|
2873
|
+
if (!f?.id) return err(new Error('could not resolve frame for state write'));
|
|
2874
|
+
await api('POST', `/api/file/${f.id}/state`, { state });
|
|
2875
|
+
return ok({ path: p, stateWritten: true, frameId: f.id });
|
|
2876
|
+
}
|
|
2877
|
+
const body = {};
|
|
2878
|
+
if (gw) {
|
|
2879
|
+
// Google Workspace frames are created via the workspace endpoint (the
|
|
2880
|
+
// label drops the .google-* extension), then populated with native actions.
|
|
2881
|
+
const label = basename(filename, extname(filename)) || filename;
|
|
2882
|
+
const result = await api('POST', '/api/google/workspace/create-frame', {
|
|
2883
|
+
projectId: getState().projectId,
|
|
2884
|
+
layer, lane, label, type: gw, title: title || label,
|
|
2885
|
+
}, orgHeader);
|
|
2886
|
+
return ok({ ...result, path: `/${layer}/${lane}/${filename}`, sourceType: gw });
|
|
2887
|
+
}
|
|
2888
|
+
else if (content) body.content = content;
|
|
2889
|
+
else if (file_path) body.file_path = file_path;
|
|
2890
|
+
else if (base64) body.base64 = base64;
|
|
2891
|
+
else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
|
|
2892
|
+
const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
|
|
2893
|
+
if (state !== undefined && result?.id) {
|
|
2894
|
+
try { await api('POST', `/api/file/${result.id}/state`, { state }); } catch { /* state best-effort */ }
|
|
2895
|
+
}
|
|
2896
|
+
return ok(result);
|
|
2897
|
+
}
|
|
2898
|
+
case 'edit': {
|
|
2899
|
+
if (!filePath || !ops?.length) return err(new Error('edit requires a full file path + ops'));
|
|
2900
|
+
const isExcalidraw = filename?.endsWith('.excalidraw');
|
|
2901
|
+
const relPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
|
|
2902
|
+
const result = isExcalidraw
|
|
2903
|
+
? await api('POST', '/api/fs/edit-excalidraw', { path: relPath, elements: ops, remove: args.remove }, orgHeader)
|
|
2904
|
+
: await api('POST', '/api/fs/edit', { path: relPath, operations: ops }, orgHeader);
|
|
2905
|
+
return ok(result);
|
|
2906
|
+
}
|
|
2907
|
+
case 'mv': {
|
|
2908
|
+
const from = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
|
|
2909
|
+
const toClean = (to || '').replace(/^\/+|\/+$/g, '');
|
|
2910
|
+
const toHasProject = toClean.startsWith('projects/');
|
|
2911
|
+
const toParts = toClean.replace(/^projects\/?/, '').split('/').filter(Boolean);
|
|
2912
|
+
let toPath, toProjectId;
|
|
2913
|
+
if (toHasProject && toParts.length >= 2) {
|
|
2914
|
+
// Full destination path: first segment after /projects is the project ref
|
|
2915
|
+
// (fs grammar: /projects/<project>/<layer>[/<lane>]/<file>). Drop it from
|
|
2916
|
+
// the path; resolve toProjectId only when it differs from the source.
|
|
2917
|
+
const toRef = toParts[0];
|
|
2918
|
+
const sameProject = projectRef && String(toRef).toLowerCase() === String(projectRef).toLowerCase();
|
|
2919
|
+
if (!sameProject) {
|
|
2920
|
+
const toMeta = await resolveProjectRef(toRef).catch(() => null);
|
|
2921
|
+
if (toMeta) toProjectId = toMeta.id;
|
|
2922
|
+
}
|
|
2923
|
+
toPath = '/' + toParts.slice(1).join('/');
|
|
2924
|
+
} else {
|
|
2925
|
+
// Bare relative path (no project prefix): /{layer}[/{lane}]/{file}
|
|
2926
|
+
toPath = toParts.length ? '/' + toParts.join('/') : (to || '');
|
|
2927
|
+
}
|
|
2928
|
+
const result = await api('POST', '/api/fs/mv', { from, to: toPath, ...(toProjectId ? { toProjectId } : {}) }, orgHeader);
|
|
2929
|
+
return ok(result);
|
|
2930
|
+
}
|
|
2931
|
+
case 'rm': {
|
|
2932
|
+
// Project-level rm = archive (soft delete): move to the reserved
|
|
2933
|
+
// __archived folder. Frame/lane rm still deletes those items, but a
|
|
2934
|
+
// whole project is never hard-deleted by an agent — the web UI's
|
|
2935
|
+
// Archive bin is where a human permanently deletes.
|
|
2936
|
+
if (!filePath) {
|
|
2937
|
+
const pid = getState().projectId;
|
|
2938
|
+
if (!pid) return err(new Error('could not resolve project id for archive'));
|
|
2939
|
+
const result = await api('PATCH', `/api/project/${pid}`, { folder: '__archived' }, orgHeader);
|
|
2940
|
+
return ok({ archived: true, project: projectRef, projectId: pid, archiveHint: 'The project now lives in the Archive bin in the web UI sidebar — a human can restore or permanently delete it there.' });
|
|
2941
|
+
}
|
|
2942
|
+
const rmPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
|
|
2943
|
+
const result = await api('DELETE', `/api/fs/${rmPath.replace(/^\//, '')}`, undefined, orgHeader);
|
|
2944
|
+
return ok(result);
|
|
2945
|
+
}
|
|
2946
|
+
case 'search': {
|
|
2947
|
+
const result = await api('GET', `/api/fs/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
|
|
2948
|
+
return ok(result);
|
|
2949
|
+
}
|
|
2950
|
+
default:
|
|
2951
|
+
return err(new Error(`fs ${action} not supported for /projects`));
|
|
2952
|
+
}
|
|
2953
|
+
};
|
|
2954
|
+
|
|
2955
|
+
// Resolve the project from the path and scope the API calls to it
|
|
2956
|
+
// (same seam the old project(action="open") provided, now path-derived).
|
|
2957
|
+
const meta = args.projectId ? await resolveProjectRef(args.projectId) : (projectRef ? await resolveProjectRef(projectRef) : null);
|
|
2958
|
+
// Shape A: the org in the path must address the project's own org. Without
|
|
2959
|
+
// this check, /o/<orgX>/projects/<projInOrgY>/... silently operated on orgY
|
|
2960
|
+
// (the old URL-form alias stripped the org segment without validation).
|
|
2961
|
+
if (orgFromPath && meta && !(await pathOrgMatches(orgFromPath, meta))) {
|
|
2962
|
+
return err(new Error(`project "${projectRef}" is not in the addressed org — /o/${orgFromPath}/... addresses ${orgFromPath}, but the project lives in ${meta.orgSlug || meta.orgId}`));
|
|
2963
|
+
}
|
|
2964
|
+
if (!meta && projectRef && (action === 'write' || action === 'edit')) {
|
|
2965
|
+
// mkdir -p semantics: writing into a non-existent project creates it.
|
|
2966
|
+
// The project name comes from the path; org resolves like any project-less
|
|
2967
|
+
// mutation (explicit org in the path / org= wins, else the bound org, else refused).
|
|
2968
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
2969
|
+
const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2970
|
+
const body = { name: projectName };
|
|
2971
|
+
if (org) body.templateSlug = undefined;
|
|
2972
|
+
let created;
|
|
2973
|
+
try {
|
|
2974
|
+
created = await api('POST', '/api/projects', body, orgHeader);
|
|
2975
|
+
} catch (e) {
|
|
2976
|
+
return err(new Error(`project "${projectRef}" not found and auto-create failed: ${e?.message || e}`));
|
|
2977
|
+
}
|
|
2978
|
+
if (!created?.id) return err(new Error(`project "${projectRef}" not found and auto-create returned no id`));
|
|
2979
|
+
const createdMeta = { id: created.id, slug: created.slug, name: created.name || projectName, orgId: created.orgId, orgSlug: created.orgSlug };
|
|
2980
|
+
projectRefCache.set(projectRef, createdMeta);
|
|
2981
|
+
projectRefCache.set(created.id, createdMeta);
|
|
2982
|
+
return withProjectOverride(createdMeta, run);
|
|
2983
|
+
}
|
|
2984
|
+
if (projectRef && !meta) return err(new Error(`project not found: ${projectRef} — pass a project slug/name/UUID from /projects, or write to a new path to create it`));
|
|
2985
|
+
return meta ? withProjectOverride(meta, run) : run();
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
return err(new Error(`unknown fs path: ${rawPath} — expected /o/<org>/wiki/..., /o/<org>/skills/..., or /o/<org>/projects/... (bare /wiki, /skills, /projects still work)`));
|
|
2989
|
+
});
|
|
2990
|
+
function normalizeMcpUpdatePolicy(policy) {
|
|
2991
|
+
const severity = policy?.policy?.severity || 'unknown';
|
|
2992
|
+
const updateAvailable = !!policy?.policy?.updateAvailable;
|
|
2993
|
+
const required = !!policy?.policy?.required;
|
|
2994
|
+
return {
|
|
2995
|
+
status: required ? 'required' : updateAvailable ? 'stale' : severity === 'unknown' ? 'unknown' : 'current',
|
|
2996
|
+
currentVersion: policy?.versions?.client || PACKAGE_VERSION,
|
|
2997
|
+
latestVersion: policy?.versions?.latest || null,
|
|
2998
|
+
recommendedVersion: policy?.versions?.recommended || null,
|
|
2999
|
+
minimumRequiredVersion: policy?.versions?.minimumRequired || null,
|
|
3000
|
+
severity,
|
|
3001
|
+
updateAvailable,
|
|
3002
|
+
stale: updateAvailable,
|
|
3003
|
+
required,
|
|
3004
|
+
reason: policy?.policy?.reason || null,
|
|
3005
|
+
enabled: policy?.policy?.enabled !== false,
|
|
3006
|
+
mode: policy?.package?.mcpMode || mcpMode(),
|
|
3007
|
+
distribution: policy?.package?.distribution || (mcpMode() === 'stdio' ? 'npm-stdio' : 'hosted-http'),
|
|
3008
|
+
update: {
|
|
3009
|
+
command: policy?.update?.command || null,
|
|
3010
|
+
helper: policy?.update?.helper || null,
|
|
3011
|
+
packageManager: policy?.update?.packageManager || 'npm',
|
|
3012
|
+
},
|
|
3013
|
+
restart: {
|
|
3014
|
+
required: !!policy?.restart?.required,
|
|
3015
|
+
guidance: policy?.restart?.guidance || null,
|
|
3016
|
+
},
|
|
3017
|
+
checkedAt: policy?.serverTimestamp || null,
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function buildInstalledMcpUpdateInstructions(updateMetadata = null) {
|
|
3022
|
+
const mode = updateMetadata?.mode || mcpMode();
|
|
3023
|
+
const restart = updateMetadata?.restart || {
|
|
3024
|
+
required: mode === 'stdio',
|
|
3025
|
+
guidance: 'Restart agents after updating the npm-installed Drafted MCP daemon.',
|
|
3026
|
+
};
|
|
3027
|
+
|
|
3028
|
+
if (mode !== 'stdio') {
|
|
3029
|
+
return {
|
|
3030
|
+
action: 'update_mcp',
|
|
3031
|
+
started: false,
|
|
3032
|
+
updateSupported: false,
|
|
3033
|
+
mode,
|
|
3034
|
+
currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
|
|
3035
|
+
latestVersion: updateMetadata?.latestVersion || null,
|
|
3036
|
+
updateAvailable: false,
|
|
3037
|
+
required: false,
|
|
3038
|
+
command: null,
|
|
3039
|
+
dryRunCommand: null,
|
|
3040
|
+
manualCommand: null,
|
|
3041
|
+
restart: {
|
|
3042
|
+
required: false,
|
|
3043
|
+
guidance: restart.guidance || 'Hosted HTTP MCP updates with the Drafted server deploy.',
|
|
3044
|
+
},
|
|
3045
|
+
note: 'This session is using hosted HTTP MCP, so there is no npm-installed stdio daemon to update on this machine.',
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
const server = getServerUrl().replace(/\/$/, '');
|
|
3050
|
+
const manualCommand = platform() === 'win32'
|
|
3051
|
+
? `$tmp = Join-Path $env:TEMP "drafted-install.ps1"; Invoke-WebRequest -UseBasicParsing "${server}/install.ps1" -OutFile $tmp; powershell -NoProfile -ExecutionPolicy Bypass -File $tmp`
|
|
3052
|
+
: `tmp=$(mktemp); curl -fsSL ${server}/install.sh -o "$tmp" && bash "$tmp"`;
|
|
3053
|
+
|
|
3054
|
+
return {
|
|
3055
|
+
action: 'update_mcp',
|
|
3056
|
+
started: false,
|
|
3057
|
+
updateSupported: true,
|
|
3058
|
+
mode: 'stdio',
|
|
3059
|
+
currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
|
|
3060
|
+
latestVersion: updateMetadata?.latestVersion || null,
|
|
3061
|
+
recommendedVersion: updateMetadata?.recommendedVersion || null,
|
|
3062
|
+
minimumRequiredVersion: updateMetadata?.minimumRequiredVersion || null,
|
|
3063
|
+
updateAvailable: !!updateMetadata?.updateAvailable,
|
|
3064
|
+
required: !!updateMetadata?.required,
|
|
3065
|
+
command: 'drafted update --yes',
|
|
3066
|
+
dryRunCommand: 'drafted update --dry-run',
|
|
3067
|
+
manualCommand,
|
|
3068
|
+
restart: {
|
|
3069
|
+
required: true,
|
|
3070
|
+
guidance: restart.guidance || 'Restart agents after updating the npm-installed Drafted MCP daemon.',
|
|
3071
|
+
},
|
|
3072
|
+
note: 'This action is intentionally advisory: it does not replace the currently running MCP process. Run the command, then restart your agent/editor so it starts the updated drafted-mcp.',
|
|
3073
|
+
mcpUpdate: updateMetadata || null,
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
async function getMcpUpdateMetadata() {
|
|
3078
|
+
const mode = mcpMode();
|
|
3079
|
+
try {
|
|
3080
|
+
const query = new URLSearchParams({
|
|
3081
|
+
cliVersion: PACKAGE_VERSION,
|
|
3082
|
+
mcpMode: mode,
|
|
3083
|
+
});
|
|
3084
|
+
const policy = await api('GET', `/api/installations/latest?${query.toString()}`);
|
|
3085
|
+
return normalizeMcpUpdatePolicy(policy);
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
return {
|
|
3088
|
+
status: 'unknown',
|
|
3089
|
+
currentVersion: PACKAGE_VERSION,
|
|
3090
|
+
latestVersion: null,
|
|
3091
|
+
recommendedVersion: null,
|
|
3092
|
+
minimumRequiredVersion: null,
|
|
3093
|
+
severity: 'unknown',
|
|
3094
|
+
updateAvailable: false,
|
|
3095
|
+
stale: false,
|
|
3096
|
+
required: false,
|
|
3097
|
+
reason: 'latest_check_failed',
|
|
3098
|
+
enabled: false,
|
|
3099
|
+
mode,
|
|
3100
|
+
distribution: mode === 'stdio' ? 'npm-stdio' : 'hosted-http',
|
|
3101
|
+
update: { command: null, helper: null, packageManager: 'npm' },
|
|
3102
|
+
restart: { required: false, guidance: 'Drafted MCP update status is unavailable; this call still succeeded.' },
|
|
3103
|
+
checkedAt: null,
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// Process-lifetime cache: the update status rides on every `whoami` (meant to be called
|
|
3109
|
+
// once per session), so avoid a network round-trip on repeat calls. `get_org` shares the
|
|
3110
|
+
// cache too (same underlying data).
|
|
3111
|
+
let mcpUpdateCache = null; // { data, fetchedAt }
|
|
3112
|
+
const MCP_UPDATE_CACHE_MS = 5 * 60_000;
|
|
3113
|
+
async function getCachedMcpUpdateMetadata() {
|
|
3114
|
+
if (mcpUpdateCache && (Date.now() - mcpUpdateCache.fetchedAt) < MCP_UPDATE_CACHE_MS) return mcpUpdateCache.data;
|
|
3115
|
+
const data = await getMcpUpdateMetadata();
|
|
3116
|
+
mcpUpdateCache = { data, fetchedAt: Date.now() };
|
|
3117
|
+
return data;
|
|
3118
|
+
}
|
|
4861
3119
|
return server;
|
|
4862
3120
|
}
|
|
4863
3121
|
|