drafted 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/drafted.mjs +129 -12
- package/mcp/server.mjs +110 -10
- package/package.json +1 -1
package/cli/drafted.mjs
CHANGED
|
@@ -1249,6 +1249,63 @@ async function unresolvableReason(res, org) {
|
|
|
1249
1249
|
return why;
|
|
1250
1250
|
}
|
|
1251
1251
|
|
|
1252
|
+
function unquoteYaml(s) {
|
|
1253
|
+
s = s.trim();
|
|
1254
|
+
if (s[0] === '"' && s[s.length - 1] === '"') { try { return JSON.parse(s); } catch { return s.slice(1, -1); } }
|
|
1255
|
+
if (s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1);
|
|
1256
|
+
return s;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Parse the `setup:` block from a SKILL.md YAML frontmatter. Handles an inline
|
|
1260
|
+
// scalar (`setup: "cmd"` / `setup: cmd`), an inline JSON array (`setup: ["a","b"]`),
|
|
1261
|
+
// and a block list (`setup:` then indented `- cmd` lines). Returns string[].
|
|
1262
|
+
function parseSetupFromFrontmatter(md) {
|
|
1263
|
+
const fm = md.match(/^---\n([\s\S]*?)\n---/);
|
|
1264
|
+
if (!fm) return [];
|
|
1265
|
+
const lines = fm[1].split('\n');
|
|
1266
|
+
const out = [];
|
|
1267
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1268
|
+
const m = lines[i].match(/^setup:(.*)$/);
|
|
1269
|
+
if (!m) continue;
|
|
1270
|
+
const rest = m[1].trim();
|
|
1271
|
+
if (rest) {
|
|
1272
|
+
if (rest.startsWith('[')) {
|
|
1273
|
+
try { const arr = JSON.parse(rest); if (Array.isArray(arr)) out.push(...arr.map(String)); } catch { /* malformed inline array */ }
|
|
1274
|
+
} else {
|
|
1275
|
+
out.push(unquoteYaml(rest));
|
|
1276
|
+
}
|
|
1277
|
+
} else {
|
|
1278
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1279
|
+
const item = lines[j].match(/^\s+-\s+(.*)$/);
|
|
1280
|
+
if (item) out.push(unquoteYaml(item[1].trim()));
|
|
1281
|
+
else if (lines[j].trim() === '') continue;
|
|
1282
|
+
else break;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
break; // only the first setup: key
|
|
1286
|
+
}
|
|
1287
|
+
return out.filter((s) => s && s.trim());
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Run a source-only skill's setup commands in <dir>, in order. Offline-safe: a
|
|
1291
|
+
// failure is reported and the loop stops, but the source in <dir> is never deleted
|
|
1292
|
+
// (the caller decides whether to keep or discard). Trusted-by-pinning — adding a
|
|
1293
|
+
// skill is the consent, same as cloning a repo and running its install; no per-cmd gate.
|
|
1294
|
+
function runSkillSetup(dir) {
|
|
1295
|
+
const skillMd = join(dir, 'SKILL.md');
|
|
1296
|
+
if (!existsSync(skillMd)) return { ok: false, skipped: true, reason: 'no SKILL.md' };
|
|
1297
|
+
let setup;
|
|
1298
|
+
try { setup = parseSetupFromFrontmatter(readFileSync(skillMd, 'utf8')); }
|
|
1299
|
+
catch (e) { return { ok: false, error: `parse SKILL.md: ${(e && e.message) || e}` }; }
|
|
1300
|
+
if (!setup.length) return { ok: true, skipped: true, ran: [] };
|
|
1301
|
+
const ran = [];
|
|
1302
|
+
for (const cmd of setup) {
|
|
1303
|
+
try { execSync(cmd, { cwd: dir, stdio: 'inherit' }); ran.push(cmd); }
|
|
1304
|
+
catch (e) { return { ok: false, failed: cmd, ran, error: String((e && e.message) || e) }; }
|
|
1305
|
+
}
|
|
1306
|
+
return { ok: true, ran };
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1252
1309
|
async function syncOneSkill(ref, outDir, org) {
|
|
1253
1310
|
const at = ref.lastIndexOf('@');
|
|
1254
1311
|
const idOrSlug = at > 0 ? ref.slice(0, at) : ref;
|
|
@@ -1295,6 +1352,7 @@ skillCmd
|
|
|
1295
1352
|
.option('--ref <ref>', 'skill ref slug[@hash] (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
|
|
1296
1353
|
.requiredOption('--out <dir>', 'output directory for bundles')
|
|
1297
1354
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
1355
|
+
.option('--no-setup', 'do not run each skill\'s setup after materializing (default: run setup so source-only skills are runnable)')
|
|
1298
1356
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1299
1357
|
.action(async (opts) => {
|
|
1300
1358
|
requireLogin();
|
|
@@ -1305,6 +1363,16 @@ skillCmd
|
|
|
1305
1363
|
for (const ref of refs) {
|
|
1306
1364
|
try {
|
|
1307
1365
|
const r = await syncOneSkill(ref, opts.out, opts.org);
|
|
1366
|
+
// Run setup by default so a synced source-only skill builds and is
|
|
1367
|
+
// immediately runnable. Hash was computed over the source BEFORE setup, so
|
|
1368
|
+
// node_modules/dist from setup never affect it. Offline-safe: a setup
|
|
1369
|
+
// failure is reported but the materialized source stays put (sync is still
|
|
1370
|
+
// `ok` — the bytes are there, just not built).
|
|
1371
|
+
if (r.status === 'ok' && opts.setup !== false) {
|
|
1372
|
+
const s = runSkillSetup(join(opts.out, r.slug));
|
|
1373
|
+
r.setup = s.skipped ? 'none' : (s.ok ? 'ok' : 'failed');
|
|
1374
|
+
if (!s.ok && !s.skipped) r.setupError = s.failed ? `${s.failed}: ${s.error}` : s.error;
|
|
1375
|
+
}
|
|
1308
1376
|
results.push(r);
|
|
1309
1377
|
if (r.status !== 'ok') allOk = false;
|
|
1310
1378
|
} catch (err) {
|
|
@@ -1316,7 +1384,8 @@ skillCmd
|
|
|
1316
1384
|
console.log(JSON.stringify(results));
|
|
1317
1385
|
} else {
|
|
1318
1386
|
for (const r of results) {
|
|
1319
|
-
|
|
1387
|
+
const setupCol = r.setup ? `\tsetup:${r.setup}` : '';
|
|
1388
|
+
console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}${setupCol}${r.error ? '\t' + r.error : ''}`);
|
|
1320
1389
|
}
|
|
1321
1390
|
}
|
|
1322
1391
|
process.exit(allOk ? 0 : 1);
|
|
@@ -1362,32 +1431,61 @@ skillCmd
|
|
|
1362
1431
|
|
|
1363
1432
|
skillCmd
|
|
1364
1433
|
.command('update')
|
|
1365
|
-
.description('Update a Drafted skill (--id or --slug) from stdin JSON {description?,content?,triggerPatterns?,tags?}')
|
|
1434
|
+
.description('Update a Drafted skill (--id or --slug) from stdin JSON {name?,description?,content?,triggerPatterns?,tags?,setup?}; auto-forks into your org if the target is read-only (global/other-org)')
|
|
1366
1435
|
.option('--id <id>', 'skill id')
|
|
1367
1436
|
.option('--slug <slug>', 'skill slug (resolved to id)')
|
|
1437
|
+
.option('--org <org>', 'fork into / resolve against this Drafted org (id or name)')
|
|
1368
1438
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1369
1439
|
.action(async (opts) => {
|
|
1370
1440
|
requireLogin();
|
|
1371
1441
|
let p;
|
|
1372
1442
|
try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
|
|
1373
1443
|
const server = getServerUrl().replace(/\/$/, '');
|
|
1444
|
+
const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
|
|
1374
1445
|
let id = opts.id;
|
|
1446
|
+
let slug = opts.slug;
|
|
1375
1447
|
if (!id && opts.slug) {
|
|
1376
|
-
const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}
|
|
1448
|
+
const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
|
|
1377
1449
|
if (!lookup.ok) { emitSkillResult(opts.format, { status: 'unresolvable', error: `slug ${opts.slug}: HTTP ${lookup.status}` }); process.exit(1); }
|
|
1378
|
-
|
|
1450
|
+
const lj = await lookup.json().catch(() => ({}));
|
|
1451
|
+
id = lj.id; slug = lj.slug;
|
|
1379
1452
|
}
|
|
1380
1453
|
if (!id) { emitSkillResult(opts.format, { status: 'error', error: 'update requires --id or --slug' }); process.exit(1); }
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1454
|
+
const payload = JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup });
|
|
1455
|
+
const putSkill = (sid) => authFetch(`${server}/api/skills/${sid}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...orgHeaders }, body: payload });
|
|
1456
|
+
|
|
1457
|
+
const res = await putSkill(id);
|
|
1385
1458
|
const data = await res.json().catch(() => ({}));
|
|
1386
|
-
if (
|
|
1387
|
-
emitSkillResult(opts.format, { status:
|
|
1388
|
-
|
|
1459
|
+
if (res.ok) {
|
|
1460
|
+
emitSkillResult(opts.format, { status: 'ok', forked: false, slug: data.slug, id: data.id, orgId: data.orgId, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
|
|
1461
|
+
return;
|
|
1389
1462
|
}
|
|
1390
|
-
|
|
1463
|
+
// Auto-fork: the target is read-only (global/system or another org). Fork it into
|
|
1464
|
+
// our org, then write the copy — a single `update` transparently forks-then-writes.
|
|
1465
|
+
// The raw PUT stayed strict (403 skill_read_only); auto-fork is a tool convenience.
|
|
1466
|
+
if (res.status === 403 && data.code === 'skill_read_only') {
|
|
1467
|
+
const forkRes = await authFetch(`${server}/api/skills/${id}/fork`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders } });
|
|
1468
|
+
let forkId;
|
|
1469
|
+
if (forkRes.ok) {
|
|
1470
|
+
forkId = (await forkRes.json().catch(() => ({}))).id;
|
|
1471
|
+
} else if (forkRes.status === 409) {
|
|
1472
|
+
// Org already owns a copy of this slug — update that one (still "forked").
|
|
1473
|
+
const ownSlug = data.slug || slug;
|
|
1474
|
+
const own = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(ownSlug)}`, { headers: orgHeaders });
|
|
1475
|
+
if (!own.ok) { emitSkillResult(opts.format, { status: 'error', error: `resolve existing fork ${ownSlug}: HTTP ${own.status}` }); process.exit(1); }
|
|
1476
|
+
forkId = (await own.json().catch(() => ({}))).id;
|
|
1477
|
+
} else {
|
|
1478
|
+
const fe = await forkRes.json().catch(() => ({}));
|
|
1479
|
+
emitSkillResult(opts.format, { status: 'error', error: `fork failed: ${fe.error || 'HTTP ' + forkRes.status}` }); process.exit(1);
|
|
1480
|
+
}
|
|
1481
|
+
const up = await putSkill(forkId);
|
|
1482
|
+
const ud = await up.json().catch(() => ({}));
|
|
1483
|
+
if (!up.ok) { emitSkillResult(opts.format, { status: 'error', error: `update fork failed: ${ud.error || 'HTTP ' + up.status}` }); process.exit(1); }
|
|
1484
|
+
emitSkillResult(opts.format, { status: 'ok', forked: true, slug: ud.slug, id: ud.id, orgId: ud.orgId, hash: ud.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(ud) }), forkedFrom: ud.forkedFrom });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
|
|
1488
|
+
process.exit(1);
|
|
1391
1489
|
});
|
|
1392
1490
|
|
|
1393
1491
|
// check reports the current canonical hash for refs WITHOUT materializing — the
|
|
@@ -1437,6 +1535,25 @@ skillCmd
|
|
|
1437
1535
|
else for (const r of results) console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}`);
|
|
1438
1536
|
});
|
|
1439
1537
|
|
|
1538
|
+
skillCmd
|
|
1539
|
+
.command('setup')
|
|
1540
|
+
.description('Run a source-only skill\'s setup commands (from <dir>/SKILL.md frontmatter) in <dir> — local, offline-safe')
|
|
1541
|
+
.requiredOption('--dir <dir>', 'skill directory containing SKILL.md')
|
|
1542
|
+
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1543
|
+
.action((opts) => {
|
|
1544
|
+
const r = runSkillSetup(resolve(opts.dir));
|
|
1545
|
+
if (opts.format === 'json') {
|
|
1546
|
+
console.log(JSON.stringify(r));
|
|
1547
|
+
} else if (r.skipped) {
|
|
1548
|
+
console.log(`skipped\t${r.reason || 'no setup'}`);
|
|
1549
|
+
} else if (r.ok) {
|
|
1550
|
+
console.log(`ok\tran ${r.ran.length}`);
|
|
1551
|
+
} else {
|
|
1552
|
+
console.log(`failed\t${r.failed || ''}\t${r.error || ''}`);
|
|
1553
|
+
}
|
|
1554
|
+
process.exit(r.ok ? 0 : 1);
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1440
1557
|
skillCmd
|
|
1441
1558
|
.command('remove')
|
|
1442
1559
|
.description('Delete a Drafted skill by --id (used to clean up throwaway skills)')
|
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, writeFileSync, mkdirSync, unlinkSync, appendFileSync } from 'fs';
|
|
13
|
+
import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, appendFileSync, readdirSync, statSync } from 'fs';
|
|
14
14
|
import { join, dirname, basename, extname, resolve } from 'path';
|
|
15
15
|
import { homedir, platform, release as osRelease, arch as osArch } from 'os';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
@@ -719,13 +719,13 @@ const MIME_MAP = {
|
|
|
719
719
|
};
|
|
720
720
|
function mimeFromExt(ext) { return MIME_MAP[ext?.toLowerCase()] || 'application/octet-stream'; }
|
|
721
721
|
|
|
722
|
-
async function api(method, path, body, _retried) {
|
|
722
|
+
async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
723
723
|
await ensureSession();
|
|
724
724
|
const pid = getState().projectId;
|
|
725
725
|
const sep = path.includes('?') ? '&' : '?';
|
|
726
726
|
const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
|
|
727
727
|
const url = `${getServerUrl()}${scopedPath}`;
|
|
728
|
-
const headers = { ...getAuthHeaders() };
|
|
728
|
+
const headers = { ...getAuthHeaders(), ...extraHeaders };
|
|
729
729
|
const opts = { method, headers };
|
|
730
730
|
|
|
731
731
|
if (body !== undefined) {
|
|
@@ -743,7 +743,7 @@ async function api(method, path, body, _retried) {
|
|
|
743
743
|
getState().sessionId = null;
|
|
744
744
|
const approved = await consumePendingDeviceCode();
|
|
745
745
|
if (!approved) await cloneSession();
|
|
746
|
-
return api(method, path, body, true);
|
|
746
|
+
return api(method, path, body, extraHeaders, true);
|
|
747
747
|
}
|
|
748
748
|
|
|
749
749
|
let data;
|
|
@@ -783,7 +783,12 @@ async function api(method, path, body, _retried) {
|
|
|
783
783
|
`Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
|
|
784
784
|
);
|
|
785
785
|
}
|
|
786
|
-
|
|
786
|
+
// Attach the structured body so callers can key off machine-readable fields
|
|
787
|
+
// (e.g. `code:"skill_read_only"` to drive auto-fork) instead of matching prose.
|
|
788
|
+
const apiErr = new Error(msg);
|
|
789
|
+
apiErr.status = res.status;
|
|
790
|
+
if (data && typeof data === 'object') { apiErr.code = data.code; apiErr.body = data; }
|
|
791
|
+
throw apiErr;
|
|
787
792
|
}
|
|
788
793
|
return data;
|
|
789
794
|
}
|
|
@@ -2612,10 +2617,45 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
|
|
|
2612
2617
|
|
|
2613
2618
|
// ── Skill library tool ───────────────────────────────────────────
|
|
2614
2619
|
|
|
2615
|
-
|
|
2620
|
+
// Walk a local source tree for `skill action=push` with dir, pre-filtering heavy
|
|
2621
|
+
// dirs + any .skillignore as a convenience so we don't ship a node_modules tree
|
|
2622
|
+
// over the wire. The SERVER re-runs the authoritative hygiene pipeline. Mirrors the
|
|
2623
|
+
// CLI's collectSkillTree.
|
|
2624
|
+
function collectSkillTreeForPush(dir) {
|
|
2625
|
+
const root = resolve(dir);
|
|
2626
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
|
|
2627
|
+
const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform']);
|
|
2628
|
+
const NUL = String.fromCharCode(0);
|
|
2629
|
+
let ignore = () => false;
|
|
2630
|
+
const ign = join(root, '.skillignore');
|
|
2631
|
+
if (existsSync(ign)) {
|
|
2632
|
+
const pats = readFileSync(ign, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
2633
|
+
ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
|
|
2634
|
+
}
|
|
2635
|
+
const out = [];
|
|
2636
|
+
const walk = (abs, rel) => {
|
|
2637
|
+
for (const name of readdirSync(abs)) {
|
|
2638
|
+
const childAbs = join(abs, name);
|
|
2639
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
2640
|
+
const st = statSync(childAbs);
|
|
2641
|
+
if (st.isDirectory()) { if (SKIP.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
|
|
2642
|
+
else if (st.isFile()) {
|
|
2643
|
+
if (ignore(childRel)) continue;
|
|
2644
|
+
const content = readFileSync(childAbs, 'utf8');
|
|
2645
|
+
if (content.includes(NUL)) continue; // skip binary; server rejects it anyway
|
|
2646
|
+
out.push({ path: childRel, content });
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
};
|
|
2650
|
+
walk(root, '');
|
|
2651
|
+
return out;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. Dispatch by `action`: search/load/list for discovery; 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.', {
|
|
2616
2655
|
action: z.enum([
|
|
2617
2656
|
'search', 'load', 'list',
|
|
2618
2657
|
'add', 'update', 'remove',
|
|
2658
|
+
'fork', 'push',
|
|
2619
2659
|
'attach', 'detach',
|
|
2620
2660
|
'favorite', 'unfavorite',
|
|
2621
2661
|
'read_file', 'update_file',
|
|
@@ -2632,6 +2672,11 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2632
2672
|
content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
|
|
2633
2673
|
triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
|
|
2634
2674
|
path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
|
|
2675
|
+
org: z.string().optional().describe('[fork|push|update] resolve/fork into this Drafted org (id or name); scopes the request without switching the session'),
|
|
2676
|
+
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"]'),
|
|
2677
|
+
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'),
|
|
2678
|
+
dir: z.string().optional().describe('[push] local directory to push instead of files[]; walked locally (heavy dirs + .skillignore pre-filtered), server re-enforces'),
|
|
2679
|
+
deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
|
|
2635
2680
|
}, async (args) => {
|
|
2636
2681
|
try {
|
|
2637
2682
|
const { action } = args;
|
|
@@ -2703,30 +2748,85 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2703
2748
|
case 'add': {
|
|
2704
2749
|
const g2 = g2Block(getSessionState().gates);
|
|
2705
2750
|
if (g2) return err(new Error(g2));
|
|
2706
|
-
const { name, description, content, tags, triggerPatterns } = args;
|
|
2751
|
+
const { name, description, content, tags, triggerPatterns, setup } = args;
|
|
2707
2752
|
if (!name || !description || !content) throw new Error('name, description, content required for action=add');
|
|
2708
2753
|
const body = { name, description, content };
|
|
2709
2754
|
if (tags) body.tags = tags;
|
|
2710
2755
|
if (triggerPatterns) body.triggerPatterns = triggerPatterns;
|
|
2756
|
+
if (setup !== undefined) body.setup = setup;
|
|
2711
2757
|
return ok(await api('POST', '/api/skills', body));
|
|
2712
2758
|
}
|
|
2713
2759
|
case 'update': {
|
|
2714
|
-
|
|
2715
|
-
|
|
2760
|
+
// Auto-fork-on-update (tool behavior): if the target is read-only (global/
|
|
2761
|
+
// system or another org), the server returns 403 {code:"skill_read_only"};
|
|
2762
|
+
// we fork into the caller's org and write the copy, returning forked:true.
|
|
2763
|
+
// The raw PUT stays strict — this composition lives in the tool, mirroring
|
|
2764
|
+
// the CLI verbatim so consumers treat CLI and MCP identically.
|
|
2765
|
+
const { skillId, skill: slugArg, name, description, content, tags, triggerPatterns, setup, org } = args;
|
|
2766
|
+
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
2767
|
+
let id = skillId;
|
|
2768
|
+
let slug = slugArg;
|
|
2769
|
+
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; slug = s.slug; }
|
|
2770
|
+
if (!id) throw new Error('skillId or skill (slug) required for action=update');
|
|
2716
2771
|
const body = {};
|
|
2717
2772
|
if (name !== undefined) body.name = name;
|
|
2718
2773
|
if (description !== undefined) body.description = description;
|
|
2719
2774
|
if (content !== undefined) body.content = content;
|
|
2720
2775
|
if (tags !== undefined) body.tags = tags;
|
|
2721
2776
|
if (triggerPatterns !== undefined) body.triggerPatterns = triggerPatterns;
|
|
2777
|
+
if (setup !== undefined) body.setup = setup;
|
|
2722
2778
|
if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
|
|
2723
|
-
|
|
2779
|
+
try {
|
|
2780
|
+
const r = await api('PUT', `/api/skills/${id}`, body, extra);
|
|
2781
|
+
return ok({ ...r, forked: false });
|
|
2782
|
+
} catch (e) {
|
|
2783
|
+
if (e.code !== 'skill_read_only') throw e;
|
|
2784
|
+
let forkId;
|
|
2785
|
+
try {
|
|
2786
|
+
forkId = (await api('POST', `/api/skills/${id}/fork`, {}, extra)).id;
|
|
2787
|
+
} catch (fe) {
|
|
2788
|
+
if (fe.status !== 409) throw fe;
|
|
2789
|
+
const ownSlug = (fe.body && fe.body.slug) || (e.body && e.body.slug) || slug;
|
|
2790
|
+
forkId = (await api('GET', `/api/skills/slug/${ownSlug}`, undefined, extra)).id;
|
|
2791
|
+
}
|
|
2792
|
+
const r2 = await api('PUT', `/api/skills/${forkId}`, body, extra);
|
|
2793
|
+
return ok({ ...r2, forked: true });
|
|
2794
|
+
}
|
|
2724
2795
|
}
|
|
2725
2796
|
case 'remove': {
|
|
2726
2797
|
const { skillId } = args;
|
|
2727
2798
|
if (!skillId) throw new Error('skillId required for action=remove');
|
|
2728
2799
|
return ok(await api('DELETE', `/api/skills/${skillId}`));
|
|
2729
2800
|
}
|
|
2801
|
+
case 'fork': {
|
|
2802
|
+
// Block-then-fork: copy a readable (global/other-org) skill into the
|
|
2803
|
+
// caller's org so it can be edited. Mirrors `drafted skill fork` verbatim.
|
|
2804
|
+
const { skillId, skill: slugArg, org } = args;
|
|
2805
|
+
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
2806
|
+
let id = skillId;
|
|
2807
|
+
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
|
|
2808
|
+
if (!id) throw new Error('skillId or skill (slug) required for action=fork');
|
|
2809
|
+
try {
|
|
2810
|
+
return ok(await api('POST', `/api/skills/${id}/fork`, {}, extra));
|
|
2811
|
+
} catch (e) {
|
|
2812
|
+
if (e.status === 409) return ok({ status: 'conflict', error: e.message }); // org already owns this slug
|
|
2813
|
+
throw e;
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
case 'push': {
|
|
2817
|
+
// Source-tree ingest. Pass files:[{path,content}] (the usual MCP shape) or a
|
|
2818
|
+
// local dir (walked here, server re-enforces the §3 ingest guards). Mirrors
|
|
2819
|
+
// `drafted skill push`.
|
|
2820
|
+
const { skillId, skill: slugArg, files, dir, deleteMissing, org } = args;
|
|
2821
|
+
const extra = org ? { 'X-Drafted-Org': org } : {};
|
|
2822
|
+
let id = skillId;
|
|
2823
|
+
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
|
|
2824
|
+
if (!id) throw new Error('skillId or skill (slug) required for action=push');
|
|
2825
|
+
let fileList = files;
|
|
2826
|
+
if (!fileList && dir) fileList = collectSkillTreeForPush(dir);
|
|
2827
|
+
if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
|
|
2828
|
+
return ok(await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra));
|
|
2829
|
+
}
|
|
2730
2830
|
case 'attach': {
|
|
2731
2831
|
const { skillId } = args;
|
|
2732
2832
|
if (!skillId) throw new Error('skillId required for action=attach');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|