drafted 1.7.17 → 1.7.18
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 +50 -0
- package/cli/drafted.mjs +12 -4
- package/install-mcp.sh +719 -34
- package/mcp/server.mjs +399 -63
- package/package.json +15 -3
- package/src/shared/constants.mjs +7 -5
- package/src/shared/excalidraw.mjs +101 -0
package/mcp/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { execFile } from 'child_process';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { readFileSync, existsSync, realpathSync } from 'fs';
|
|
13
|
+
import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
14
14
|
import { join, dirname, basename, extname, resolve } from 'path';
|
|
15
15
|
import { homedir } from 'os';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
@@ -19,6 +19,8 @@ import { z } from 'zod';
|
|
|
19
19
|
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
20
20
|
import WebSocket from 'ws';
|
|
21
21
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
|
+
import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
23
|
+
import { UMAMI_EVENTS, trackUmamiEvent } from '../server/lib/umami.mjs';
|
|
22
24
|
|
|
23
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
@@ -41,8 +43,8 @@ const PACKAGE_VERSION = (() => {
|
|
|
41
43
|
const requestState = new AsyncLocalStorage();
|
|
42
44
|
const standaloneState = { sessionId: null, projectId: null, projectMeta: null };
|
|
43
45
|
|
|
44
|
-
// Per-MCP-session sticky state. Keyed by
|
|
45
|
-
// or '__stdio__' for the long-lived stdio process. Holds active-project,
|
|
46
|
+
// Per-MCP-session sticky state. Keyed by the Drafted session id bound to
|
|
47
|
+
// this MCP instance, or '__stdio__' for the long-lived stdio process. Holds active-project,
|
|
46
48
|
// loaded-skill, and cached-org state that must persist across HTTP request
|
|
47
49
|
// boundaries so a `project open` followed by `frame.read` in a separate
|
|
48
50
|
// request still has an active project.
|
|
@@ -63,6 +65,7 @@ function getOrCreateSessionState(sid) {
|
|
|
63
65
|
cachedOrgId: null,
|
|
64
66
|
cachedOrgIdTime: 0,
|
|
65
67
|
wsSessionId: null,
|
|
68
|
+
pendingDeviceCode: null,
|
|
66
69
|
};
|
|
67
70
|
sessionStates.set(key, s);
|
|
68
71
|
}
|
|
@@ -99,6 +102,7 @@ export function runWithRequestState(initial, fn) {
|
|
|
99
102
|
// Stdio mode uses the `mcpServer` singleton (built once at module load).
|
|
100
103
|
|
|
101
104
|
export function createMcpServer() {
|
|
105
|
+
trackUmamiEvent(UMAMI_EVENTS.MCP_CONNECTED, { source: 'mcp' });
|
|
102
106
|
const server = new McpServer({
|
|
103
107
|
name: 'drafted',
|
|
104
108
|
version: PACKAGE_VERSION,
|
|
@@ -112,6 +116,9 @@ SKILLS: Drafted has a skill library -- reusable agent instructions stored as SKI
|
|
|
112
116
|
|
|
113
117
|
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(<frameId>). One line per related frame. Skip for throwaway or exploratory frames.
|
|
114
118
|
|
|
119
|
+
CONTEXT RULES (follow these before every action):
|
|
120
|
+
- WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use wiki(action="search") with relevant keywords.
|
|
121
|
+
- 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.
|
|
115
122
|
IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.`,
|
|
116
123
|
});
|
|
117
124
|
|
|
@@ -129,11 +136,11 @@ const layerKeys = Object.keys(LAYERS);
|
|
|
129
136
|
|
|
130
137
|
const TOOL_ANNOTATIONS = {
|
|
131
138
|
// Auth — initiates external browser / email flows
|
|
132
|
-
auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL
|
|
139
|
+
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.' },
|
|
133
140
|
|
|
134
141
|
// Projects
|
|
135
142
|
project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (switch active project), create, update, move to another org.' },
|
|
136
|
-
get_org: { title: 'Organization', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Get the active organization (action="get", default) or switch to a different org (action="switch", orgId=...). Use switch when you need wiki/skill work in an org that has no projects — opening a project also switches, but is unavailable in empty orgs.' },
|
|
143
|
+
get_org: { title: 'Organization', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Get the active organization (action="get", default), Google Drive availability, or switch to a different org (action="switch", orgId=...). Use switch when you need wiki/skill work in an org that has no projects — opening a project also switches, but is unavailable in empty orgs. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
|
|
137
144
|
|
|
138
145
|
// Templates
|
|
139
146
|
template: { title: 'Templates', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage project templates: list, create, update, delete, fork.' },
|
|
@@ -143,7 +150,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
143
150
|
|
|
144
151
|
// Frames — filesystem
|
|
145
152
|
ls: { title: 'List frames', readOnlyHint: true, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html' },
|
|
146
|
-
frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, or
|
|
153
|
+
frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, search, or restore frame versions in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
|
|
147
154
|
rm: { title: 'Delete frame', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
148
155
|
// batch: { title: 'Batch operations', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
149
156
|
|
|
@@ -193,7 +200,12 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
193
200
|
if (ann.widgetUri) {
|
|
194
201
|
config._meta = { 'ui': { resourceUri: ann.widgetUri } };
|
|
195
202
|
}
|
|
196
|
-
return server.registerTool(name, config,
|
|
203
|
+
return server.registerTool(name, config, async (...args) => {
|
|
204
|
+
const state = getState();
|
|
205
|
+
trackUmamiEvent(UMAMI_EVENTS.MCP_TOOL_CALLED, { tool: name, projectId: state.projectId || undefined, source: 'mcp' });
|
|
206
|
+
reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_REQUEST);
|
|
207
|
+
return cb(...args);
|
|
208
|
+
});
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
// ── ChatGPT Apps SDK widgets ──────────────────────────────────────
|
|
@@ -255,20 +267,63 @@ registerAppResource(
|
|
|
255
267
|
// ── Config ────────────────────────────────────────────────────────
|
|
256
268
|
|
|
257
269
|
const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
|
|
270
|
+
const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}.pending`;
|
|
258
271
|
|
|
259
272
|
function getServerUrl() {
|
|
260
273
|
if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
|
|
274
|
+
if (getState().publicUrl) return getState().publicUrl.replace(/\/$/, '');
|
|
275
|
+
if (process.env.DRAFTED_PUBLIC_URL) return process.env.DRAFTED_PUBLIC_URL.replace(/\/$/, '');
|
|
276
|
+
if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, '');
|
|
277
|
+
if (process.env.APP_URL) return process.env.APP_URL.replace(/\/$/, '');
|
|
261
278
|
// Read from config.json next to auth.json (written by install-mcp.sh)
|
|
262
279
|
try {
|
|
263
280
|
const cfgPath = join(homedir(), '.drafted', 'config.json');
|
|
264
281
|
if (existsSync(cfgPath)) {
|
|
265
282
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
266
283
|
if (cfg.server) return cfg.server.replace(/\/$/, '');
|
|
284
|
+
if (cfg.publicUrl) return cfg.publicUrl.replace(/\/$/, '');
|
|
267
285
|
}
|
|
268
286
|
} catch { /* fall through */ }
|
|
269
287
|
return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
|
|
270
288
|
}
|
|
271
289
|
|
|
290
|
+
function getInstallInfo() {
|
|
291
|
+
if (process.env.DRAFTED_TELEMETRY === '0') return null;
|
|
292
|
+
try {
|
|
293
|
+
const installPath = join(homedir(), '.drafted', 'install.json');
|
|
294
|
+
if (!existsSync(installPath)) return null;
|
|
295
|
+
const info = JSON.parse(readFileSync(installPath, 'utf8'));
|
|
296
|
+
if (info.telemetry === false) return null;
|
|
297
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(info.installId || ''))) return null;
|
|
298
|
+
return info;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function reportInstallationEvent(event) {
|
|
305
|
+
const info = getInstallInfo();
|
|
306
|
+
if (!info) return;
|
|
307
|
+
fetch(`${getServerUrl()}/api/installations/report`, {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': `Drafted MCP/${PACKAGE_VERSION}` },
|
|
310
|
+
body: JSON.stringify({
|
|
311
|
+
installId: info.installId,
|
|
312
|
+
event,
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
cliVersion: PACKAGE_VERSION,
|
|
315
|
+
mcpMode: process.argv.includes('--http') ? 'http' : 'stdio',
|
|
316
|
+
source: 'mcp',
|
|
317
|
+
}),
|
|
318
|
+
}).catch(() => {});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function wikiBrowserUrl(path = '') {
|
|
322
|
+
const normalized = normalizeWikiPath(path || '');
|
|
323
|
+
if (!normalized) return `${getServerUrl()}/wiki`;
|
|
324
|
+
return `${getServerUrl()}/wiki/${normalized.split('/').map(encodeURIComponent).join('/')}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
272
327
|
function getBootstrapSessionId() {
|
|
273
328
|
try {
|
|
274
329
|
if (existsSync(AUTH_FILE)) {
|
|
@@ -279,6 +334,87 @@ function getBootstrapSessionId() {
|
|
|
279
334
|
return null;
|
|
280
335
|
}
|
|
281
336
|
|
|
337
|
+
function persistAuthSession(data) {
|
|
338
|
+
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
339
|
+
writeFileSync(AUTH_FILE, JSON.stringify({
|
|
340
|
+
sessionId: data.sessionId,
|
|
341
|
+
userId: data.userId || null,
|
|
342
|
+
orgId: data.orgId || null,
|
|
343
|
+
server: getServerUrl(),
|
|
344
|
+
updatedAt: new Date().toISOString(),
|
|
345
|
+
}, null, 2), { mode: 0o600 });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function persistPendingDeviceCode(data) {
|
|
349
|
+
if (!data?.deviceCode) return;
|
|
350
|
+
const pending = {
|
|
351
|
+
...data,
|
|
352
|
+
server: getServerUrl(),
|
|
353
|
+
createdAt: Date.now(),
|
|
354
|
+
expiresAt: Date.now() + (Number(data.expiresIn || 900) * 1000),
|
|
355
|
+
};
|
|
356
|
+
getSessionState().pendingDeviceCode = pending;
|
|
357
|
+
try {
|
|
358
|
+
mkdirSync(dirname(PENDING_AUTH_FILE), { recursive: true });
|
|
359
|
+
writeFileSync(PENDING_AUTH_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
360
|
+
} catch { /* best effort; in-memory state still works for long-lived stdio */ }
|
|
361
|
+
schedulePendingAuthPoll(2000);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function clearPendingDeviceCode() {
|
|
365
|
+
stopPendingAuthPoll();
|
|
366
|
+
getSessionState().pendingDeviceCode = null;
|
|
367
|
+
try { if (existsSync(PENDING_AUTH_FILE)) unlinkSync(PENDING_AUTH_FILE); } catch { /* ignore */ }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getPendingDeviceCode() {
|
|
371
|
+
const inMemory = getSessionState().pendingDeviceCode;
|
|
372
|
+
if (inMemory?.deviceCode) return inMemory;
|
|
373
|
+
try {
|
|
374
|
+
if (!existsSync(PENDING_AUTH_FILE)) return null;
|
|
375
|
+
const pending = JSON.parse(readFileSync(PENDING_AUTH_FILE, 'utf8'));
|
|
376
|
+
if (!pending?.deviceCode) return null;
|
|
377
|
+
if (pending.server && pending.server !== getServerUrl()) return null;
|
|
378
|
+
if (pending.expiresAt && Date.now() > Number(pending.expiresAt)) {
|
|
379
|
+
clearPendingDeviceCode();
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
getSessionState().pendingDeviceCode = pending;
|
|
383
|
+
return pending;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
let pendingAuthPollTimer = null;
|
|
391
|
+
|
|
392
|
+
function stopPendingAuthPoll() {
|
|
393
|
+
if (pendingAuthPollTimer) clearTimeout(pendingAuthPollTimer);
|
|
394
|
+
pendingAuthPollTimer = null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function schedulePendingAuthPoll(delayMs = 2000) {
|
|
398
|
+
stopPendingAuthPoll();
|
|
399
|
+
pendingAuthPollTimer = setTimeout(async () => {
|
|
400
|
+
pendingAuthPollTimer = null;
|
|
401
|
+
const pending = getPendingDeviceCode();
|
|
402
|
+
if (!pending?.deviceCode) return;
|
|
403
|
+
const approved = await consumePendingDeviceCode();
|
|
404
|
+
if (approved) {
|
|
405
|
+
try { connectAgentWs().catch(() => {}); } catch { /* ignore */ }
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const expiresAt = Number(pending.expiresAt || 0);
|
|
409
|
+
if (expiresAt && Date.now() >= expiresAt) {
|
|
410
|
+
clearPendingDeviceCode();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
schedulePendingAuthPoll(2000);
|
|
414
|
+
}, delayMs);
|
|
415
|
+
if (typeof pendingAuthPollTimer.unref === 'function') pendingAuthPollTimer.unref();
|
|
416
|
+
}
|
|
417
|
+
|
|
282
418
|
function getAuthHeaders() {
|
|
283
419
|
const sid = getState().sessionId || getBootstrapSessionId();
|
|
284
420
|
if (sid) return { Cookie: `gc_session=${sid}` };
|
|
@@ -304,6 +440,8 @@ async function cloneSession() {
|
|
|
304
440
|
}
|
|
305
441
|
|
|
306
442
|
async function ensureSession() {
|
|
443
|
+
if (getState().sessionId) return;
|
|
444
|
+
await consumePendingDeviceCode();
|
|
307
445
|
if (getState().sessionId) return;
|
|
308
446
|
await cloneSession();
|
|
309
447
|
}
|
|
@@ -335,10 +473,13 @@ async function api(method, path, body, _retried) {
|
|
|
335
473
|
const res = await fetch(url, opts);
|
|
336
474
|
const text = await res.text();
|
|
337
475
|
|
|
338
|
-
// Session expired after server restart
|
|
476
|
+
// Session expired after server restart, or a browser approval just completed
|
|
477
|
+
// for auth(action="get_link"). First try to consume any pending device code,
|
|
478
|
+
// then fall back to cloning the saved browser session, and retry once.
|
|
339
479
|
if (res.status === 401 && !_retried) {
|
|
340
480
|
getState().sessionId = null;
|
|
341
|
-
await
|
|
481
|
+
const approved = await consumePendingDeviceCode();
|
|
482
|
+
if (!approved) await cloneSession();
|
|
342
483
|
return api(method, path, body, true);
|
|
343
484
|
}
|
|
344
485
|
|
|
@@ -398,6 +539,22 @@ function ok(text, opts) {
|
|
|
398
539
|
return result;
|
|
399
540
|
}
|
|
400
541
|
|
|
542
|
+
function summarizeSkillForSearch(skill) {
|
|
543
|
+
if (!skill || typeof skill !== 'object') return skill;
|
|
544
|
+
return {
|
|
545
|
+
id: skill.id,
|
|
546
|
+
orgId: skill.orgId ?? null,
|
|
547
|
+
name: skill.name,
|
|
548
|
+
slug: skill.slug,
|
|
549
|
+
description: skill.description,
|
|
550
|
+
tags: skill.tags || [],
|
|
551
|
+
triggerPatterns: skill.triggerPatterns || [],
|
|
552
|
+
version: skill.version,
|
|
553
|
+
updatedAt: skill.updatedAt,
|
|
554
|
+
fileCount: Array.isArray(skill.files) ? skill.files.length : undefined,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
401
558
|
// Build the structuredContent shape that the frame-preview widget reads.
|
|
402
559
|
// Tools that produce or return a frame (read/write/edit) call this so the
|
|
403
560
|
// model and widget see the same metadata view.
|
|
@@ -694,7 +851,7 @@ function applyHashlineOps(content, operations) {
|
|
|
694
851
|
let lines = content.split('\n');
|
|
695
852
|
const lineToHash = {};
|
|
696
853
|
for (let i = 0; i < lines.length; i++) {
|
|
697
|
-
lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0,
|
|
854
|
+
lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 12);
|
|
698
855
|
}
|
|
699
856
|
const sorted = [...operations].reverse();
|
|
700
857
|
for (const op of sorted) {
|
|
@@ -744,10 +901,49 @@ function runCLI(command, args = [], options = {}) {
|
|
|
744
901
|
|
|
745
902
|
// ── Login tools ─────────────────────────────────────────────────────
|
|
746
903
|
|
|
747
|
-
//
|
|
748
|
-
|
|
904
|
+
// Per-session pending device code — auth(action=get_link) stores it, and
|
|
905
|
+
// auth(action=login) or a later tool call can consume it.
|
|
906
|
+
//
|
|
907
|
+
// This prevents the "I already authed" loop after get_link: once the user
|
|
908
|
+
// finishes the browser step, the next tool call can exchange the approved
|
|
909
|
+
// device code for a Drafted session automatically.
|
|
910
|
+
//
|
|
911
|
+
// SCOPE: This `auth` tool is for the stdio path only — when drafted-mcp runs
|
|
912
|
+
// as a local Node process (npm-installed `drafted-mcp` for self-hosters,
|
|
913
|
+
// Cursor/VS Code one-click installs that use stdio). The Claude Code plugin
|
|
914
|
+
// has used HTTP MCP since v1.7.17 and authenticates via Clerk OAuth at the
|
|
915
|
+
// MCP protocol layer (handled by Claude Code itself, see src/middleware/oauth.ts).
|
|
916
|
+
// HTTP-mode callers never reach this tool because Clerk gates all /mcp tool
|
|
917
|
+
// calls before authentication. Don't add new auth flows here — add them to
|
|
918
|
+
// src/auth/clerk.ts instead.
|
|
919
|
+
|
|
920
|
+
async function consumePendingDeviceCode() {
|
|
921
|
+
const pending = getPendingDeviceCode();
|
|
922
|
+
if (!pending?.deviceCode) return false;
|
|
923
|
+
|
|
924
|
+
try {
|
|
925
|
+
const res = await fetch(`${getServerUrl()}/auth/device/token`, {
|
|
926
|
+
method: 'POST',
|
|
927
|
+
headers: { 'Content-Type': 'application/json' },
|
|
928
|
+
body: JSON.stringify({ deviceCode: pending.deviceCode }),
|
|
929
|
+
});
|
|
930
|
+
if (!res.ok) return false;
|
|
931
|
+
const data = await res.json();
|
|
932
|
+
if (data.status === 'approved' && data.sessionId) {
|
|
933
|
+
persistAuthSession(data);
|
|
934
|
+
getState().sessionId = data.sessionId;
|
|
935
|
+
clearPendingDeviceCode();
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
if (data.status === 'expired') {
|
|
939
|
+
clearPendingDeviceCode();
|
|
940
|
+
}
|
|
941
|
+
} catch { /* ignore and fall through */ }
|
|
942
|
+
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
749
945
|
|
|
750
|
-
tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open). `action=login` opens a browser and polls for approval
|
|
946
|
+
tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open) and starts background 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. If get_link was called first, login reuses that pending code instead of opening a new browser.', {
|
|
751
947
|
action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
|
|
752
948
|
}, async ({ action }) => {
|
|
753
949
|
try {
|
|
@@ -755,7 +951,7 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
|
|
|
755
951
|
const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
756
952
|
if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
|
|
757
953
|
const data = await codeRes.json();
|
|
758
|
-
|
|
954
|
+
persistPendingDeviceCode(data);
|
|
759
955
|
return ok(data.verificationUrl);
|
|
760
956
|
}
|
|
761
957
|
if (action === 'login') {
|
|
@@ -775,9 +971,9 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
|
|
|
775
971
|
let deviceCode, verificationUrl, expiresIn;
|
|
776
972
|
let reusingPending = false;
|
|
777
973
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
974
|
+
const pending = getPendingDeviceCode();
|
|
975
|
+
if (pending) {
|
|
976
|
+
({ deviceCode, verificationUrl, expiresIn } = pending);
|
|
781
977
|
reusingPending = true;
|
|
782
978
|
} else {
|
|
783
979
|
const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
@@ -813,16 +1009,8 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
|
|
|
813
1009
|
const data = await res.json();
|
|
814
1010
|
|
|
815
1011
|
if (data.status === 'approved') {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
819
|
-
writeFileSync(AUTH_FILE, JSON.stringify({
|
|
820
|
-
sessionId: data.sessionId,
|
|
821
|
-
userId: data.userId || null,
|
|
822
|
-
orgId: data.orgId || null,
|
|
823
|
-
server: getServerUrl(),
|
|
824
|
-
updatedAt: new Date().toISOString(),
|
|
825
|
-
}, null, 2));
|
|
1012
|
+
persistAuthSession(data);
|
|
1013
|
+
clearPendingDeviceCode();
|
|
826
1014
|
|
|
827
1015
|
getState().sessionId = null;
|
|
828
1016
|
await cloneSession();
|
|
@@ -1171,7 +1359,7 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1171
1359
|
const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
|
|
1172
1360
|
const entries = listing.entries || [];
|
|
1173
1361
|
const frames = entries.filter(e => e.type === 'frame');
|
|
1174
|
-
const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md'));
|
|
1362
|
+
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'));
|
|
1175
1363
|
const frameCount = realFrames.length;
|
|
1176
1364
|
if (frameCount > 0) {
|
|
1177
1365
|
throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
|
|
@@ -1209,6 +1397,32 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1209
1397
|
} catch (error) { return err(error); }
|
|
1210
1398
|
});
|
|
1211
1399
|
|
|
1400
|
+
|
|
1401
|
+
async function getGoogleDriveAvailability() {
|
|
1402
|
+
try {
|
|
1403
|
+
const status = await api('GET', '/api/google/status');
|
|
1404
|
+
return {
|
|
1405
|
+
connected: !!status?.connected,
|
|
1406
|
+
syncEnabled: !!status?.syncEnabled,
|
|
1407
|
+
driveRootFolderId: status?.driveRootFolderId || null,
|
|
1408
|
+
driveRootFolderName: status?.driveRootFolderName || null,
|
|
1409
|
+
workspaceFramesAvailable: !!status?.connected,
|
|
1410
|
+
preference: status?.connected
|
|
1411
|
+
? 'Strongly prefer Google Workspace frames for docs, sheets, and slides in this org.'
|
|
1412
|
+
: 'Google Drive is not connected; use normal Drafted frames.',
|
|
1413
|
+
};
|
|
1414
|
+
} catch {
|
|
1415
|
+
return {
|
|
1416
|
+
connected: false,
|
|
1417
|
+
syncEnabled: false,
|
|
1418
|
+
driveRootFolderId: null,
|
|
1419
|
+
driveRootFolderName: null,
|
|
1420
|
+
workspaceFramesAvailable: false,
|
|
1421
|
+
preference: 'Google Drive status unavailable; use normal Drafted frames unless a Google Workspace frame succeeds.',
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1212
1426
|
tool('get_org', {
|
|
1213
1427
|
action: z.enum(['get', 'switch']).optional().describe('Default: "get" returns active org and member info. Use "switch" with orgId to change the active org without opening a project.'),
|
|
1214
1428
|
orgId: z.string().optional().describe('[switch] target org ID to switch to. Must be one of the orgs the user is a member of.'),
|
|
@@ -1229,7 +1443,8 @@ tool('get_org', {
|
|
|
1229
1443
|
const me = await api('GET', '/auth/me');
|
|
1230
1444
|
const orgs = (await api('GET', '/api/orgs')).orgs || [];
|
|
1231
1445
|
const activeOrg = (orgs || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name })).find(o => o.id === me?.orgId) || null;
|
|
1232
|
-
|
|
1446
|
+
const googleDrive = await getGoogleDriveAvailability();
|
|
1447
|
+
return ok({ switched: true, activeOrg, googleDrive, note: 'Active org switched. Wiki and skill calls now target this org. Active project cleared — open a project (or stay org-scoped for wiki/skill). If googleDrive.connected is true, prefer Google Workspace frames for docs, sheets, and slides.' });
|
|
1233
1448
|
}
|
|
1234
1449
|
|
|
1235
1450
|
// Source of truth = THIS MCP session's bound org (what mutations will actually
|
|
@@ -1242,6 +1457,8 @@ tool('get_org', {
|
|
|
1242
1457
|
const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
1243
1458
|
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
1244
1459
|
|
|
1460
|
+
const googleDrive = await getGoogleDriveAvailability();
|
|
1461
|
+
|
|
1245
1462
|
let members = [];
|
|
1246
1463
|
if (sessionOrgId) {
|
|
1247
1464
|
try {
|
|
@@ -1253,27 +1470,34 @@ tool('get_org', {
|
|
|
1253
1470
|
activeOrg,
|
|
1254
1471
|
orgs,
|
|
1255
1472
|
members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
|
|
1473
|
+
googleDrive,
|
|
1256
1474
|
mcpVersion: PACKAGE_VERSION,
|
|
1257
|
-
note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. To switch orgs without creating a project, call get_org(action=\"switch\", orgId=\"...\"). Browser tabs and other MCP sessions for the same user can be on different orgs.",
|
|
1475
|
+
note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. To switch orgs without creating a project, call get_org(action=\"switch\", orgId=\"...\"). Browser tabs and other MCP sessions for the same user can be on different orgs. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
|
|
1258
1476
|
});
|
|
1259
1477
|
} catch (error) { return err(error); }
|
|
1260
1478
|
});
|
|
1261
1479
|
|
|
1262
1480
|
// ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
|
|
1263
1481
|
|
|
1264
|
-
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), 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**Write — content or
|
|
1265
|
-
action: z.enum(['read', 'write', 'edit', 'mv', 'anchor', 'search']).describe('Operation to perform.'),
|
|
1482
|
+
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), write_excalidraw (native editable Excalidraw diagram), 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**Write — content, file, or Google Workspace frame:** Provide `content` (HTML/markdown/text), `file_path` (absolute path to a local file), `googleType` (`google-doc`, `google-sheet`, `google-slide`), OR `write_excalidraw` with `excalidraw_data` or `mermaid`. Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for business artifacts: use `google-doc` for memos/reports/briefs/SOPs/proposals, `google-sheet` for tables/trackers/budgets/research matrices/models, and `google-slide` for decks/presentation outlines. 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`. For binary files (images, PDFs), use `file_path` — uploaded to storage and displayed as an asset frame.\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`.', {
|
|
1483
|
+
action: z.enum(['read', 'write', 'write_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform.'),
|
|
1266
1484
|
path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
|
|
1267
1485
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
1268
|
-
content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path.'),
|
|
1486
|
+
content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path. Use a .html path for complete HTML documents and a .md path for Markdown.'),
|
|
1487
|
+
excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene.'),
|
|
1488
|
+
mermaid: z.string().optional().describe('[write_excalidraw] Mermaid source to convert into an editable Excalidraw scene.'),
|
|
1269
1489
|
file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content.'),
|
|
1490
|
+
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.'),
|
|
1491
|
+
title: z.string().optional().describe('[write + googleType] Title for a new Google Doc/Sheet/Slide. Defaults to filename from path.'),
|
|
1492
|
+
url: z.string().optional().describe('[write + googleType] Existing Google Doc/Sheet/Slide URL to attach.'),
|
|
1493
|
+
googleId: z.string().optional().describe('[write + googleType] Existing Google file ID to attach.'),
|
|
1270
1494
|
autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
|
|
1271
1495
|
width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
|
|
1272
1496
|
height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
|
|
1273
1497
|
color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
|
|
1274
1498
|
operations: z.array(z.object({
|
|
1275
1499
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
1276
|
-
lineHash: z.string().describe('
|
|
1500
|
+
lineHash: z.string().describe('Hash of the target line (from read output). Use the full hash to avoid ambiguity in large frames.'),
|
|
1277
1501
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
1278
1502
|
})).optional().describe('[edit] hashline edit operations'),
|
|
1279
1503
|
from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
|
|
@@ -1283,6 +1507,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1283
1507
|
query: z.string().optional().describe('[search] term to match against frame names'),
|
|
1284
1508
|
projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
|
|
1285
1509
|
limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
|
|
1510
|
+
versionId: z.string().optional().describe('[read_version|restore_version] version id'),
|
|
1511
|
+
reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
|
|
1286
1512
|
}, async (args) => {
|
|
1287
1513
|
try {
|
|
1288
1514
|
const { action } = args;
|
|
@@ -1330,28 +1556,55 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1330
1556
|
});
|
|
1331
1557
|
}
|
|
1332
1558
|
case 'write': {
|
|
1333
|
-
const { path, content, file_path, autoSize, width, height, color } = args;
|
|
1559
|
+
const { path, content, file_path, autoSize, width, height, color, googleType, title, url, googleId } = args;
|
|
1334
1560
|
if (!path) throw new Error('path required for action=write');
|
|
1335
|
-
|
|
1336
|
-
if (
|
|
1561
|
+
const writeSources = [content != null, !!file_path, !!googleType].filter(Boolean).length;
|
|
1562
|
+
if (writeSources > 1) throw new Error('Provide only one of content, file_path, or googleType');
|
|
1563
|
+
if (writeSources === 0) throw new Error('Provide content, file_path, or googleType');
|
|
1337
1564
|
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1338
1565
|
if (skillErr) return err(new Error(skillErr));
|
|
1339
1566
|
const anchorErr = await checkAnchors(parseLayer(path));
|
|
1340
1567
|
if (anchorErr) return err(new Error(anchorErr));
|
|
1341
1568
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
1342
1569
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
1570
|
+
if (googleType) {
|
|
1571
|
+
const projectId = getState().projectId;
|
|
1572
|
+
if (!projectId) throw new Error('Open a project first with project(action="open") before creating Google Workspace frames');
|
|
1573
|
+
const label = basename(parts[2], extname(parts[2])) || parts[2];
|
|
1574
|
+
const body = {
|
|
1575
|
+
projectId,
|
|
1576
|
+
layer: parts[0],
|
|
1577
|
+
lane: parts[1],
|
|
1578
|
+
label,
|
|
1579
|
+
type: googleType,
|
|
1580
|
+
width,
|
|
1581
|
+
height,
|
|
1582
|
+
};
|
|
1583
|
+
const result = url || googleId
|
|
1584
|
+
? await api('POST', '/api/google/workspace/frame', { ...body, url, googleId })
|
|
1585
|
+
: await api('POST', '/api/google/workspace/create-frame', { ...body, title: title || label });
|
|
1586
|
+
return ok(withProject(withFrameBreadcrumb({
|
|
1587
|
+
...result,
|
|
1588
|
+
path,
|
|
1589
|
+
label,
|
|
1590
|
+
contentType: 'text/html',
|
|
1591
|
+
sourceType: googleType,
|
|
1592
|
+
}, { hint: true })), {
|
|
1593
|
+
structuredContent: frameStructuredContent({ ...result, path, label, contentType: 'text/html' }, projectCtx),
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1343
1596
|
let body;
|
|
1344
1597
|
if (file_path) {
|
|
1345
1598
|
const resolved = resolve(file_path);
|
|
1346
1599
|
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
1347
1600
|
const ext = extname(resolved).toLowerCase();
|
|
1348
|
-
const TEXT_EXTS = ['.html', '.htm', '.
|
|
1601
|
+
const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
|
|
1349
1602
|
if (TEXT_EXTS.includes(ext)) {
|
|
1350
1603
|
body = { content: readFileSync(resolved, 'utf8') };
|
|
1351
1604
|
if (autoSize) body.autoSize = true;
|
|
1352
1605
|
} else {
|
|
1353
1606
|
const buffer = readFileSync(resolved);
|
|
1354
|
-
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
|
|
1607
|
+
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' };
|
|
1355
1608
|
body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
|
|
1356
1609
|
}
|
|
1357
1610
|
} else {
|
|
@@ -1367,6 +1620,28 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1367
1620
|
_meta: body.content ? { frameHtml: body.content } : undefined,
|
|
1368
1621
|
});
|
|
1369
1622
|
}
|
|
1623
|
+
case 'write_excalidraw': {
|
|
1624
|
+
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
1625
|
+
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
1626
|
+
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
1627
|
+
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1628
|
+
if (skillErr) return err(new Error(skillErr));
|
|
1629
|
+
const anchorErr = await checkAnchors(parseLayer(path));
|
|
1630
|
+
if (anchorErr) return err(new Error(anchorErr));
|
|
1631
|
+
const parts = path.replace(/^\/+/, '').split('/');
|
|
1632
|
+
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
1633
|
+
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
1634
|
+
const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
|
|
1635
|
+
const body = { content: stringifyExcalidrawScene(scene) };
|
|
1636
|
+
if (width) body.width = width;
|
|
1637
|
+
if (height) body.height = height;
|
|
1638
|
+
if (color) body.color = color;
|
|
1639
|
+
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
|
|
1640
|
+
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
1641
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
1642
|
+
_meta: { frameHtml: body.content },
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1370
1645
|
case 'edit': {
|
|
1371
1646
|
const { path, operations } = args;
|
|
1372
1647
|
if (!path) throw new Error('path required for action=edit');
|
|
@@ -1412,6 +1687,27 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1412
1687
|
if (skillErr) return err(new Error(skillErr));
|
|
1413
1688
|
return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
|
|
1414
1689
|
}
|
|
1690
|
+
|
|
1691
|
+
case 'versions': {
|
|
1692
|
+
const { path } = args;
|
|
1693
|
+
if (!path) throw new Error('path required for action=versions');
|
|
1694
|
+
const result = await api('GET', `/api/fs/versions?path=${encodeURIComponent(path)}`);
|
|
1695
|
+
return ok(withProject(result));
|
|
1696
|
+
}
|
|
1697
|
+
case 'read_version': {
|
|
1698
|
+
const { versionId } = args;
|
|
1699
|
+
if (!versionId) throw new Error('versionId required for action=read_version');
|
|
1700
|
+
const result = await api('GET', `/api/fs/versions/${versionId}`);
|
|
1701
|
+
return ok(withProject(result));
|
|
1702
|
+
}
|
|
1703
|
+
case 'restore_version': {
|
|
1704
|
+
const { versionId, reason } = args;
|
|
1705
|
+
if (!versionId) throw new Error('versionId required for action=restore_version');
|
|
1706
|
+
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1707
|
+
if (skillErr) return err(new Error(skillErr));
|
|
1708
|
+
const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
|
|
1709
|
+
return ok(withProject(result));
|
|
1710
|
+
}
|
|
1415
1711
|
case 'search': {
|
|
1416
1712
|
const { query, projectId, limit = 50 } = args;
|
|
1417
1713
|
if (!query) throw new Error('query required for action=search');
|
|
@@ -1587,7 +1883,7 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
1587
1883
|
|
|
1588
1884
|
// Handle frame operations via the batch API
|
|
1589
1885
|
if (frameOps.length > 0) {
|
|
1590
|
-
const TEXT_EXTS = ['.html', '.htm', '.
|
|
1886
|
+
const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
|
|
1591
1887
|
const resolvedOps = frameOps.map(op => {
|
|
1592
1888
|
if (op.tool === 'write' && op.file_path) {
|
|
1593
1889
|
const resolved = resolve(op.file_path);
|
|
@@ -1805,7 +2101,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1805
2101
|
]).describe('Operation to perform.'),
|
|
1806
2102
|
query: z.string().optional().describe('[search] term to match against name/description/content'),
|
|
1807
2103
|
tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
|
|
1808
|
-
scope: z.enum(['all', 'org', 'global']).optional().describe('[search] scope (default: all)'),
|
|
2104
|
+
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)'),
|
|
1809
2105
|
limit: z.number().optional().describe('[search] max results (default 25, max 100)'),
|
|
1810
2106
|
skill: z.string().optional().describe('[load] skill ID (UUID) or slug'),
|
|
1811
2107
|
skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
|
|
@@ -1829,10 +2125,14 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1829
2125
|
const endpoint = query ? '/api/skills/search' : '/api/skills';
|
|
1830
2126
|
const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
|
|
1831
2127
|
const cap = Math.min(Math.max(1, limit || 25), 100);
|
|
1832
|
-
if (Array.isArray(result?.skills)
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2128
|
+
if (Array.isArray(result?.skills)) {
|
|
2129
|
+
if (result.skills.length > cap) {
|
|
2130
|
+
result.totalAvailable = result.skills.length;
|
|
2131
|
+
result.truncated = true;
|
|
2132
|
+
result.skills = result.skills.slice(0, cap);
|
|
2133
|
+
}
|
|
2134
|
+
result.skills = result.skills.map(summarizeSkillForSearch);
|
|
2135
|
+
result.note = 'Search/list returns skill summaries only. Use skill(action="load", skill="<slug-or-id>") to read full SKILL.md content and supporting file list.';
|
|
1836
2136
|
}
|
|
1837
2137
|
return ok(result);
|
|
1838
2138
|
}
|
|
@@ -1846,6 +2146,18 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1846
2146
|
return ok(result);
|
|
1847
2147
|
}
|
|
1848
2148
|
case 'list': {
|
|
2149
|
+
if (args.scope || args.tags?.length) {
|
|
2150
|
+
const params = new URLSearchParams();
|
|
2151
|
+
params.set('scope', args.scope || 'all');
|
|
2152
|
+
if (args.tags?.length) params.set('tags', args.tags.join(','));
|
|
2153
|
+
const result = await api('GET', `/api/skills?${params.toString()}`);
|
|
2154
|
+
if (Array.isArray(result?.skills)) {
|
|
2155
|
+
result.skills = result.skills.map(summarizeSkillForSearch);
|
|
2156
|
+
result.note = 'Library list returns skill summaries only. Use skill(action="load", skill="<slug-or-id>") to read full SKILL.md content and supporting file list.';
|
|
2157
|
+
}
|
|
2158
|
+
return ok(result);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
1849
2161
|
// Prefer the explicit projectId param; otherwise the active project;
|
|
1850
2162
|
// otherwise fall back to org-attached skills so list works in
|
|
1851
2163
|
// empty-org / wiki-only sessions where there's no project to bind to.
|
|
@@ -1951,7 +2263,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
1951
2263
|
frontmatter: z.any().optional().describe('[write] frontmatter object'),
|
|
1952
2264
|
operations: z.array(z.object({
|
|
1953
2265
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
1954
|
-
lineHash: z.string().describe('
|
|
2266
|
+
lineHash: z.string().describe('Hash of the target line (from read output)'),
|
|
1955
2267
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
1956
2268
|
})).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
|
|
1957
2269
|
from: z.string().optional().describe('[mv] source path'),
|
|
@@ -2006,6 +2318,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2006
2318
|
title: p.title,
|
|
2007
2319
|
type: p.type,
|
|
2008
2320
|
id: p.id,
|
|
2321
|
+
url: wikiBrowserUrl(p.path),
|
|
2009
2322
|
}));
|
|
2010
2323
|
return ok({ tree });
|
|
2011
2324
|
}
|
|
@@ -2021,8 +2334,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2021
2334
|
}
|
|
2022
2335
|
return ok({
|
|
2023
2336
|
tree: [
|
|
2024
|
-
...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d })),
|
|
2025
|
-
...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id })),
|
|
2337
|
+
...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d, url: wikiBrowserUrl(d) })),
|
|
2338
|
+
...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) })),
|
|
2026
2339
|
],
|
|
2027
2340
|
});
|
|
2028
2341
|
}
|
|
@@ -2033,14 +2346,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2033
2346
|
const prefix = parent + '/';
|
|
2034
2347
|
for (const p of pages) {
|
|
2035
2348
|
if (p.path === parent) {
|
|
2036
|
-
children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
|
|
2349
|
+
children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
|
|
2037
2350
|
} else if (p.path.startsWith(prefix)) {
|
|
2038
2351
|
const rest = p.path.slice(prefix.length);
|
|
2039
2352
|
if (rest.includes('/')) {
|
|
2040
2353
|
const sub = rest.split('/')[0];
|
|
2041
|
-
if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub }); }
|
|
2354
|
+
if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub, url: wikiBrowserUrl(parent + '/' + sub) }); }
|
|
2042
2355
|
} else {
|
|
2043
|
-
children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
|
|
2356
|
+
children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
|
|
2044
2357
|
}
|
|
2045
2358
|
}
|
|
2046
2359
|
}
|
|
@@ -2055,12 +2368,12 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2055
2368
|
.filter(p => p.updatedAt)
|
|
2056
2369
|
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
|
2057
2370
|
.slice(0, Math.max(1, recentLimit))
|
|
2058
|
-
.map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt }));
|
|
2371
|
+
.map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt, url: wikiBrowserUrl(p.path) }));
|
|
2059
2372
|
return ok({ pages: recent });
|
|
2060
2373
|
}
|
|
2061
2374
|
|
|
2062
2375
|
// ── read ────────────────────────────────────────────────────
|
|
2063
|
-
// Returns content in hashline format (`
|
|
2376
|
+
// Returns content in hashline format (`LINE+ID|content`) so the
|
|
2064
2377
|
// agent can produce hashline edit operations. Mirrors frame.read.
|
|
2065
2378
|
case 'read': {
|
|
2066
2379
|
const { path: readPath, lines: readLines } = args;
|
|
@@ -2088,6 +2401,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2088
2401
|
lastEditedBy: page.updatedBy,
|
|
2089
2402
|
lastEditedAt: page.updatedAt,
|
|
2090
2403
|
backlinkCount,
|
|
2404
|
+
url: wikiBrowserUrl(page.path),
|
|
2091
2405
|
});
|
|
2092
2406
|
}
|
|
2093
2407
|
|
|
@@ -2096,7 +2410,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2096
2410
|
const { query: searchQuery, limit: searchLimit = 25 } = args;
|
|
2097
2411
|
if (!searchQuery) throw new Error('query required for action=search');
|
|
2098
2412
|
const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
|
|
2099
|
-
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title }));
|
|
2413
|
+
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
|
|
2100
2414
|
return ok({ hits });
|
|
2101
2415
|
}
|
|
2102
2416
|
|
|
@@ -2131,13 +2445,13 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2131
2445
|
title: 'Log',
|
|
2132
2446
|
content: entry + '\n',
|
|
2133
2447
|
});
|
|
2134
|
-
return ok(withOrg({ appended: true, created: true, pageId: created.id }));
|
|
2448
|
+
return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
|
|
2135
2449
|
}
|
|
2136
2450
|
|
|
2137
2451
|
// Append to existing log
|
|
2138
2452
|
const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
|
|
2139
2453
|
await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
|
|
2140
|
-
return ok(withOrg({ appended: true }));
|
|
2454
|
+
return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
|
|
2141
2455
|
}
|
|
2142
2456
|
|
|
2143
2457
|
// ── health ──────────────────────────────────────────────────
|
|
@@ -2160,17 +2474,39 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2160
2474
|
try {
|
|
2161
2475
|
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2162
2476
|
const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body);
|
|
2163
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true }));
|
|
2477
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
|
|
2164
2478
|
} catch {
|
|
2165
2479
|
const result = await api('POST', '/api/wiki/pages', body);
|
|
2166
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true }));
|
|
2480
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
|
|
2167
2481
|
}
|
|
2168
2482
|
}
|
|
2169
2483
|
|
|
2170
2484
|
// ── edit ────────────────────────────────────────────────────
|
|
2171
2485
|
// Hashlines come from `read` (which now formats content as
|
|
2172
|
-
// `
|
|
2486
|
+
// `LINE+ID|content`). The server applies the ops via the same
|
|
2173
2487
|
// hashline algorithm — no client-side re-hashing, no algorithm drift.
|
|
2488
|
+
case 'write_excalidraw': {
|
|
2489
|
+
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
2490
|
+
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
2491
|
+
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
2492
|
+
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2493
|
+
if (skillErr) return err(new Error(skillErr));
|
|
2494
|
+
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2495
|
+
if (anchorErr) return err(new Error(anchorErr));
|
|
2496
|
+
const parts = path.replace(/^\/+/, '').split('/');
|
|
2497
|
+
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2498
|
+
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
2499
|
+
const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
|
|
2500
|
+
const body = { content: stringifyExcalidrawScene(scene) };
|
|
2501
|
+
if (width) body.width = width;
|
|
2502
|
+
if (height) body.height = height;
|
|
2503
|
+
if (color) body.color = color;
|
|
2504
|
+
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
|
|
2505
|
+
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
2506
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
2507
|
+
_meta: { frameHtml: body.content },
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2174
2510
|
case 'edit': {
|
|
2175
2511
|
const { path: editPath, operations: editOps } = args;
|
|
2176
2512
|
if (!editPath) throw new Error('path required for action=edit');
|
|
@@ -2178,7 +2514,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2178
2514
|
const normalized = normalizeWikiPath(editPath);
|
|
2179
2515
|
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2180
2516
|
const result = await api('POST', `/api/wiki/pages/${page.id}/edit`, { operations: editOps });
|
|
2181
|
-
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied }));
|
|
2517
|
+
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path) }));
|
|
2182
2518
|
}
|
|
2183
2519
|
|
|
2184
2520
|
// ── mv ──────────────────────────────────────────────────────
|
|
@@ -2199,7 +2535,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2199
2535
|
|
|
2200
2536
|
// Server-side cascade: /move rewrites referrers in one transaction
|
|
2201
2537
|
const moved = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: toPath });
|
|
2202
|
-
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0 }));
|
|
2538
|
+
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path) }));
|
|
2203
2539
|
}
|
|
2204
2540
|
|
|
2205
2541
|
// ── rm ──────────────────────────────────────────────────────
|
|
@@ -2251,8 +2587,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2251
2587
|
return ok(withOrg({
|
|
2252
2588
|
created: result.created?.length ?? 0,
|
|
2253
2589
|
updated: result.updated?.length ?? 0,
|
|
2254
|
-
|
|
2255
|
-
|
|
2590
|
+
createdPages: (result.created ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
|
|
2591
|
+
updatedPages: (result.updated ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
|
|
2256
2592
|
errors: result.errors ?? [],
|
|
2257
2593
|
}));
|
|
2258
2594
|
}
|