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 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,tags?,triggerPatterns?} — a README.md (readme field) is required (Phase 1 gate)')
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 required when authoring a NEW skill at /o/<org>/skills/<slug> (the Phase 1 README gate rejects a new skill without one). Stored as a supporting skill file so the skill directory is self-documenting.'),
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 = p === '/skills' || p === '/skills/' ? '' : p.replace(/^\/skills\/?/, '').split('/')[0];
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: just that skill, not the whole root.
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
- return ok(s ? [{ slug: s.slug, name: s.name, description: s.description }] : []);
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
- const result = existing
2980
- ? await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader)
2981
- : await api('POST', '/api/skills', { slug, name, content: text, description, ...(readme ? { readme } : {}) }, orgHeader);
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.2",
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` is required (the `readme` param) so the skill directory is self-documenting. `fs(mv, path="/o/<org>/skills/<old>", to="/o/<org>/skills/<new>")` renames.
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.