drafted 1.18.2 → 1.18.3
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 +1 -1
- package/mcp/server.mjs +52 -8
- package/mcp/test-skill-paths.mjs +29 -0
- package/package.json +1 -1
- package/plugin/skills/drafted/SKILL.md +1 -1
package/cli/drafted.mjs
CHANGED
|
@@ -1679,7 +1679,7 @@ function emitSkillResult(format, obj) {
|
|
|
1679
1679
|
|
|
1680
1680
|
skillCmd
|
|
1681
1681
|
.command('add')
|
|
1682
|
-
.description('Create a skill in Drafted from stdin JSON {name,description,content,readme
|
|
1682
|
+
.description('Create a skill in Drafted from stdin JSON {name,description,content,readme?,tags?,triggerPatterns?} — readme is optional; omitting it returns the skill with a warning, not an error')
|
|
1683
1683
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1684
1684
|
.action(async (opts) => {
|
|
1685
1685
|
requireLogin();
|
package/mcp/server.mjs
CHANGED
|
@@ -273,6 +273,19 @@ export function stripUrlOrigin(p) {
|
|
|
273
273
|
try { return new URL(s).pathname; } catch { return s; }
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// A skill is a DIRECTORY: /skills/<slug>/<file>. SKILL.md is the skill's content
|
|
277
|
+
// (the skills table row); every other path is a supporting file (README.md,
|
|
278
|
+
// references/, scripts/) living in skill_files. The handler used to keep only
|
|
279
|
+
// segment 0, so `write /skills/<slug>/README.md` silently rewrote SKILL.md — and
|
|
280
|
+
// the create-time README gate then looked unsatisfiable from this tool.
|
|
281
|
+
// Pure + exported (asserted in mcp/test-skill-paths.mjs).
|
|
282
|
+
export function splitSkillPath(p) {
|
|
283
|
+
const rest = /^\/skills\/?$/.test(p || '') ? '' : String(p || '').replace(/^\/skills\/?/, '');
|
|
284
|
+
const segs = rest.split('/').filter(Boolean);
|
|
285
|
+
const filePath = segs.slice(1).join('/');
|
|
286
|
+
return { slug: segs[0] || '', filePath, isSkillMd: !filePath || filePath === 'SKILL.md' };
|
|
287
|
+
}
|
|
288
|
+
|
|
276
289
|
|
|
277
290
|
export function createMcpServer(transport) {
|
|
278
291
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
@@ -1543,8 +1556,11 @@ async function connectAgentWs() {
|
|
|
1543
1556
|
// suggestedName (basename of the working directory) lets the server offer it in the
|
|
1544
1557
|
// name-before-work gate message and as the tab placeholder.
|
|
1545
1558
|
try { agentWs.send(JSON.stringify({ type: 'agent-hello', agentLabel: getAgentLabel(), suggestedName: getSuggestedSessionName() })); } catch {}
|
|
1559
|
+
// Same open-race guard as the hello above: the socket can leave 'open' before
|
|
1560
|
+
// this second send lands, and an unguarded throw here kills the stdio child
|
|
1561
|
+
// (taking the agent's whole MCP connection with it) for a presence message.
|
|
1546
1562
|
if (getState().projectId) {
|
|
1547
|
-
agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() }));
|
|
1563
|
+
try { agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() })); } catch {}
|
|
1548
1564
|
}
|
|
1549
1565
|
});
|
|
1550
1566
|
|
|
@@ -2759,7 +2775,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2759
2775
|
ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames. Apply ONLY against a fresh fs(read) of the frame: each op = {type, lineHash, newContent} where type is one of replace | delete | insertAfter | insertBefore, and lineHash is the FULL anchor from the read output (line number + hash, e.g. the token left of the |, like 42srt) — NOT a bare hash. delete/replace consume the target line; at most one replace/delete per line per edit (combine into one replace). If an op targets a line that changed since read it is rejected — re-read and retry. Element ops for excalidraw ({id,x,y,...}), or structured ops for office'),
|
|
2760
2776
|
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.'),
|
|
2761
2777
|
metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
|
|
2762
|
-
readme: z.string().optional().describe('[write] README.md markdown
|
|
2778
|
+
readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> — optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
|
|
2763
2779
|
recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
|
|
2764
2780
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
|
|
2765
2781
|
pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
|
|
@@ -2933,16 +2949,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2933
2949
|
|
|
2934
2950
|
// ── Root: /skills/... ──────────────────────────────────────────
|
|
2935
2951
|
if (p.startsWith('/skills')) {
|
|
2936
|
-
const slug
|
|
2952
|
+
const { slug, filePath, isSkillMd } = splitSkillPath(p);
|
|
2953
|
+
const fileUrl = (id) => `/api/skills/${id}/files/${filePath.split('/').map(encodeURIComponent).join('/')}`;
|
|
2937
2954
|
if (['write', 'mv', 'rm'].includes(action)) {
|
|
2938
2955
|
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
2939
2956
|
}
|
|
2940
2957
|
switch (action) {
|
|
2941
2958
|
case 'ls': {
|
|
2942
|
-
// A specific slug is a targeted ls:
|
|
2959
|
+
// A specific slug is a targeted ls: the skill's directory — SKILL.md
|
|
2960
|
+
// plus its supporting files, so a README is discoverable.
|
|
2943
2961
|
if (slug) {
|
|
2944
2962
|
const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2945
|
-
|
|
2963
|
+
if (!s) return ok([]);
|
|
2964
|
+
const listed = await api('GET', `/api/skills/${s.id}/files`, undefined, orgHeader).catch(() => null);
|
|
2965
|
+
const files = (listed?.files || []).map((f) => f.path || f).filter((f) => f !== 'SKILL.md');
|
|
2966
|
+
return ok({ slug: s.slug, name: s.name, description: s.description, files: ['SKILL.md', ...files] });
|
|
2946
2967
|
}
|
|
2947
2968
|
const list = await api('GET', '/api/skills', undefined, orgHeader);
|
|
2948
2969
|
const skills = Array.isArray(list) ? list : (list?.skills || []);
|
|
@@ -2950,6 +2971,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2950
2971
|
}
|
|
2951
2972
|
case 'read': {
|
|
2952
2973
|
if (!slug) return err(new Error('read /skills/<slug>'));
|
|
2974
|
+
if (!isSkillMd) {
|
|
2975
|
+
const owner = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2976
|
+
if (!owner?.id) return err(new Error(`no Drafted-authored skill "${slug}" — supporting files exist only for skills stored in Drafted; a repo-indexed skill lives in git (read its SKILL.md with fs(read, path="/skills/${slug}"))`));
|
|
2977
|
+
const f = await api('GET', fileUrl(owner.id), undefined, orgHeader).catch(() => null);
|
|
2978
|
+
if (f?.content == null) return err(new Error(`file not found in skill ${slug}: ${filePath} (fs(ls, path="/skills/${slug}") lists what is there)`));
|
|
2979
|
+
return ok(f.content);
|
|
2980
|
+
}
|
|
2953
2981
|
let s = null;
|
|
2954
2982
|
try { s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader); } catch { /* not in the skills table — fall through to the repo index */ }
|
|
2955
2983
|
if (s) return ok(s?.content || '');
|
|
@@ -2968,6 +2996,15 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2968
2996
|
}
|
|
2969
2997
|
if (!text) return err(new Error('write /skills/<slug> requires content or file_path'));
|
|
2970
2998
|
const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2999
|
+
if (!isSkillMd) {
|
|
3000
|
+
// Supporting file inside an existing skill. It can't create the skill —
|
|
3001
|
+
// the skill row is what owns the file — so say what call does.
|
|
3002
|
+
if (!existing?.id) {
|
|
3003
|
+
return err(new Error(`skill ${slug} does not exist yet — create it first with fs(write, path="/skills/${slug}", content=<SKILL.md markdown>) (add readme=<README.md markdown> to ship a README in the same call), then write supporting files into it.`));
|
|
3004
|
+
}
|
|
3005
|
+
const written = await api('PUT', fileUrl(existing.id), { content: text }, orgHeader);
|
|
3006
|
+
return ok(written || { slug, path: filePath, written: true });
|
|
3007
|
+
}
|
|
2971
3008
|
if (!existing) {
|
|
2972
3009
|
const g2 = g2Block(gs);
|
|
2973
3010
|
if (g2) return err(new Error(g2));
|
|
@@ -2976,9 +3013,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2976
3013
|
// Derive a description from the slug when the agent didn't pass one
|
|
2977
3014
|
// (the API requires a non-empty description on create).
|
|
2978
3015
|
const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3016
|
+
let result;
|
|
3017
|
+
if (existing) {
|
|
3018
|
+
result = await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader);
|
|
3019
|
+
// A `readme` passed with an update is a README write, not a no-op.
|
|
3020
|
+
if (readme) await api('PUT', `/api/skills/${existing.id}/files/README.md`, { content: readme }, orgHeader);
|
|
3021
|
+
} else {
|
|
3022
|
+
result = await api('POST', '/api/skills', { slug, name, content: text, description, ...(readme ? { readme } : {}) }, orgHeader);
|
|
3023
|
+
}
|
|
2982
3024
|
// Writing an archived skill restores it (update flow un-archives).
|
|
2983
3025
|
if (result?.id && existing?.archived) {
|
|
2984
3026
|
try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
|
|
@@ -2987,6 +3029,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2987
3029
|
}
|
|
2988
3030
|
case 'mv': {
|
|
2989
3031
|
if (!to || !to.startsWith('/skills')) return err(new Error('mv within /skills requires to=/skills/...'));
|
|
3032
|
+
if (!isSkillMd) return err(new Error('mv moves a whole skill (/skills/<slug>), not a file inside one — rewrite the file at its new path and rm the old one'));
|
|
2990
3033
|
const toSlug = to.replace(/^\/skills\/?/, '').split('/')[0];
|
|
2991
3034
|
const result = await api('POST', '/api/skills/fork', { from: slug, to: toSlug, ...(org ? { org } : {}) }, orgHeader);
|
|
2992
3035
|
return ok(result);
|
|
@@ -2994,6 +3037,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2994
3037
|
case 'rm': {
|
|
2995
3038
|
const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
|
|
2996
3039
|
if (!s?.id) return err(new Error(`skill not found: ${slug}`));
|
|
3040
|
+
if (!isSkillMd) return ok(await api('DELETE', fileUrl(s.id), undefined, orgHeader));
|
|
2997
3041
|
return ok(await api('DELETE', `/api/skills/${s.id}`, undefined, orgHeader));
|
|
2998
3042
|
}
|
|
2999
3043
|
case 'search': {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Regression for the /skills path split used by fs(ls|read|write|rm).
|
|
2
|
+
// Run: `node mcp/test-skill-paths.mjs`. No framework — asserts the pure core.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: a skill is a directory. Before this split the handler kept
|
|
5
|
+
// only the first segment, so `fs(write, path="/skills/<slug>/README.md")` wrote
|
|
6
|
+
// SKILL.md instead — the agent's README landed on top of the skill body and the
|
|
7
|
+
// create-time README gate stayed unsatisfiable. isSkillMd is the load-bearing
|
|
8
|
+
// bit: true routes to the skills row, false routes to skill_files.
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { splitSkillPath } from './server.mjs';
|
|
11
|
+
|
|
12
|
+
// Root and bare slug — no file, so the skill row itself.
|
|
13
|
+
assert.deepEqual(splitSkillPath('/skills'), { slug: '', filePath: '', isSkillMd: true });
|
|
14
|
+
assert.deepEqual(splitSkillPath('/skills/'), { slug: '', filePath: '', isSkillMd: true });
|
|
15
|
+
assert.deepEqual(splitSkillPath('/skills/my-skill'), { slug: 'my-skill', filePath: '', isSkillMd: true });
|
|
16
|
+
assert.deepEqual(splitSkillPath('/skills/my-skill/'), { slug: 'my-skill', filePath: '', isSkillMd: true });
|
|
17
|
+
|
|
18
|
+
// SKILL.md is the skill body, addressed explicitly — same destination as the bare slug.
|
|
19
|
+
assert.deepEqual(splitSkillPath('/skills/my-skill/SKILL.md'), { slug: 'my-skill', filePath: 'SKILL.md', isSkillMd: true });
|
|
20
|
+
|
|
21
|
+
// Everything else is a supporting file, and the subpath survives.
|
|
22
|
+
assert.deepEqual(splitSkillPath('/skills/my-skill/README.md'), { slug: 'my-skill', filePath: 'README.md', isSkillMd: false });
|
|
23
|
+
assert.deepEqual(splitSkillPath('/skills/my-skill/references/api.md'), { slug: 'my-skill', filePath: 'references/api.md', isSkillMd: false });
|
|
24
|
+
|
|
25
|
+
// Case matters: readme.md is a different file from README.md, and neither is SKILL.md.
|
|
26
|
+
assert.equal(splitSkillPath('/skills/s/skill.md').isSkillMd, false);
|
|
27
|
+
|
|
28
|
+
console.log('skill path split ok — SKILL.md routes to the skill row, everything else to skill files');
|
|
29
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.3",
|
|
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": [
|
|
@@ -80,7 +80,7 @@ These bookend the loop. Prime/feed at the start, deposit at the end.
|
|
|
80
80
|
## Skill authoring
|
|
81
81
|
|
|
82
82
|
- **Git is the source of truth for skills; Drafted renders, indexes, and searches.** When a repo is connected to a folder, the skills under `.agents/skills/` in that repo ARE the skills for that folder — authoring happens by committing to git, not by writing into Drafted. Drafted write paths for a repo-owned skill return `409 repo_owned` naming the repo, branch, and path to commit to. Use `repo(action="rescan")` after a push so the index reflects the change.
|
|
83
|
-
- **For a folder with NO connected repo, author skills in Drafted** with `fs(write, path="/o/<org>/skills/<slug>", content=..., readme=...)` (G2 gate first). A `README.md`
|
|
83
|
+
- **For a folder with NO connected repo, author skills in Drafted** with `fs(write, path="/o/<org>/skills/<slug>", content=..., readme=...)` (G2 gate first). A `README.md` (the `readme` param) is optional but recommended — it is what renders for the skill directory on GitHub if the skill is ever exported; omitting it creates the skill with a warning. A skill is a directory: `fs(write, path="/o/<org>/skills/<slug>/README.md", content=...)` and any other supporting file work once the skill exists, and `fs(ls, path="/o/<org>/skills/<slug>")` lists them. `fs(mv, path="/o/<org>/skills/<old>", to="/o/<org>/skills/<new>")` renames.
|
|
84
84
|
- **Knowledge goes in the wiki, procedure in skills.** Drafted owns org knowledge (wiki pages via `fs` under `/o/<org>/wiki/`); git owns reusable procedure (skills under `.agents/skills/`). Don't put a skill body in a wiki page or a knowledge doc in a SKILL.md.
|
|
85
85
|
- **Machine-specific build output is never portable.** Build `node_modules`, downloaded browsers, compiled binaries into a `.skillinstall/` directory inside the skill — Drafted always strips it on push and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe live in git/Drafted.
|
|
86
86
|
- **Improve skills when you find a better way.** Fix or distill a skill that underperformed rather than leaving it stale.
|