drafted 1.17.3 → 1.17.5

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
@@ -15,6 +15,7 @@ import { homedir, tmpdir, platform } from 'os';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { DESIGN_SYSTEM_PROMPT, buildDesignPrompt } from './prompts.mjs';
18
+ import { scanRepoAgents, detectDefaultBranch, detectRemoteUrl, isGitUrl, cloneToTemp } from './repo-scan.mjs';
18
19
 
19
20
  const __filename = fileURLToPath(import.meta.url);
20
21
  const __dirname = dirname(__filename);
@@ -2113,4 +2114,120 @@ minionCmd
2113
2114
  emitMinionResult(opts.format, { status: 'ok', id: data.id || id });
2114
2115
  });
2115
2116
 
2117
+ // ── repo: register a git repo and index its .agents/ (Causeway seam) ──────
2118
+ // org substrate normalization, Move C. The CLI is the seam: it scans a repo's
2119
+ // `.agents/skills/<name>/SKILL.md` and `.agents/identities/<slug>/identity.yaml`
2120
+ // and POSTs the catalog to /api/repos. Content stays in git; Drafted keeps a
2121
+ // read-only index for browse/search. `repo add` is idempotent (re-scan on re-add),
2122
+ // so content edits in git are reflected with no pin bump. No Drafted token in the
2123
+ // daemon — it shells out to this verb.
2124
+ const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities)');
2125
+
2126
+ repoCmd
2127
+ .command('add <urlOrPath>')
2128
+ .description('Register a git repo (URL or local path) and ingest its .agents/ skills/identities into the org index')
2129
+ .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2130
+ .option('--slug <slug>', 'repo slug (defaults to the repo name from the URL)')
2131
+ .option('--description <desc>', 'short description')
2132
+ .option('--branch <branch>', 'default branch (defaults to the checked-out branch, then main)')
2133
+ .option('--format <fmt>', 'output format: json or text', 'text')
2134
+ .action(async (urlOrPath, opts) => {
2135
+ requireLogin();
2136
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
2137
+ // Resolve the source dir to scan + the gitUrl to store.
2138
+ let dir;
2139
+ let cleanup = () => {};
2140
+ let gitUrl;
2141
+ let defaultBranch;
2142
+ if (isGitUrl(urlOrPath)) {
2143
+ try {
2144
+ const c = cloneToTemp(urlOrPath);
2145
+ dir = c.dir; cleanup = c.cleanup;
2146
+ } catch (err) {
2147
+ console.error(`❌ Failed to clone ${urlOrPath}: ${err.message}`);
2148
+ process.exit(1);
2149
+ }
2150
+ gitUrl = urlOrPath;
2151
+ defaultBranch = opts.branch || detectDefaultBranch(dir);
2152
+ } else if (existsSync(urlOrPath)) {
2153
+ dir = resolve(urlOrPath);
2154
+ gitUrl = opts.slug ? undefined : detectRemoteUrl(dir);
2155
+ defaultBranch = opts.branch || detectDefaultBranch(dir);
2156
+ } else {
2157
+ console.error(`❌ Not a git URL and not a local path: ${urlOrPath}`);
2158
+ process.exit(1);
2159
+ }
2160
+ try {
2161
+ const { skills, identities } = scanRepoAgents(dir);
2162
+ const entries = [...skills, ...identities];
2163
+ const payload = {
2164
+ gitUrl: gitUrl || urlOrPath,
2165
+ slug: opts.slug,
2166
+ description: opts.description,
2167
+ defaultBranch,
2168
+ entries,
2169
+ };
2170
+ const server = getServerUrl().replace(/\/$/, '');
2171
+ const res = await authFetch(`${server}/api/repos`, {
2172
+ method: 'POST',
2173
+ headers: { 'Content-Type': 'application/json', ...orgHeaders },
2174
+ body: JSON.stringify(payload),
2175
+ });
2176
+ const data = await res.json().catch(() => ({}));
2177
+ if (!res.ok) {
2178
+ const err = data.error || `HTTP ${res.status}`;
2179
+ if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:add', error: err }));
2180
+ else console.log(['error', err].join('\t'));
2181
+ process.exit(1);
2182
+ }
2183
+ const created = res.status === 201;
2184
+ if (opts.format === 'json') {
2185
+ console.log(JSON.stringify({ ok: true, command: 'repo:add', data: { ...data, created, skills: skills.length, identities: identities.length } }));
2186
+ } else {
2187
+ console.log([created ? 'added' : 'updated', data.slug || '', data.gitUrl || gitUrl || '', data.defaultBranch || defaultBranch, `skills:${skills.length}`, `identities:${identities.length}`].join('\t'));
2188
+ }
2189
+ } finally {
2190
+ cleanup();
2191
+ }
2192
+ });
2193
+
2194
+ repoCmd
2195
+ .command('list')
2196
+ .description('List the org\'s registered repos with their .agents/ index counts')
2197
+ .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2198
+ .option('--format <fmt>', 'output format: json or text', 'text')
2199
+ .action(async (opts) => {
2200
+ const data = await readApiGet('repo:list', '/api/repos', opts.org);
2201
+ const rows = data.repos || [];
2202
+ if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
2203
+ for (const r of rows) console.log([r.slug, r.gitUrl, r.defaultBranch, `skills:${r.skillCount ?? 0}`, `identities:${r.identityCount ?? 0}`, r.description || ''].join('\t'));
2204
+ });
2205
+
2206
+ repoCmd
2207
+ .command('remove <slugOrId>')
2208
+ .description('Remove a registered repo (cascades its index entries)')
2209
+ .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2210
+ .option('--format <fmt>', 'output format: json or text', 'text')
2211
+ .action(async (slugOrId, opts) => {
2212
+ requireLogin();
2213
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
2214
+ const server = getServerUrl().replace(/\/$/, '');
2215
+ // UUID? DELETE directly. Otherwise resolve slug -> id via the list.
2216
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
2217
+ let id = isUuid ? slugOrId : null;
2218
+ if (!id) {
2219
+ const lookup = await authFetch(`${server}/api/repos`, { headers: orgHeaders });
2220
+ const lj = await lookup.json().catch(() => ({}));
2221
+ if (!lookup.ok) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: lj.error || `HTTP ${lookup.status}` })); else console.log(['error', lj.error || `HTTP ${lookup.status}`].join('\t')); process.exit(1); }
2222
+ const found = (lj.repos || []).find((r) => r.slug === slugOrId);
2223
+ if (!found) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: `no repo with slug "${slugOrId}"` })); else console.log(['error', `no repo with slug "${slugOrId}"`].join('\t')); process.exit(1); }
2224
+ id = found.id;
2225
+ }
2226
+ const res = await authFetch(`${server}/api/repos/${encodeURIComponent(id)}`, { method: 'DELETE', headers: orgHeaders });
2227
+ const data = await res.json().catch(() => ({}));
2228
+ if (!res.ok) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: data.error || `HTTP ${res.status}` })); else console.log(['error', data.error || `HTTP ${res.status}`].join('\t')); process.exit(1); }
2229
+ if (opts.format === 'json') console.log(JSON.stringify({ ok: true, command: 'repo:remove', data: { id } }));
2230
+ else console.log(['removed', id].join('\t'));
2231
+ });
2232
+
2116
2233
  program.parse();
@@ -0,0 +1,146 @@
1
+ // Repo `.agents/` scanner (org substrate normalization, Move C).
2
+ //
3
+ // Pure filesystem scan — no network, no DB. The CLI runs this against a repo
4
+ // checkout (cloned to a temp dir for a URL, or a local path directly) and POSTs
5
+ // the catalog to /api/repos. Content stays in git; only metadata + pointers are
6
+ // sent. Mirrors the Agent Skills open format: `.agents/skills/<name>/SKILL.md`
7
+ // (name + description frontmatter) and `.agents/identities/<slug>/identity.yaml`
8
+ // (slug + name). See docs/plans/org-substrate-normalization.md.
9
+
10
+ import { readFileSync, readdirSync, existsSync, mkdtempSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+ import { execSync } from 'child_process';
14
+ import { parse as parseYaml } from 'yaml';
15
+
16
+ // ── Frontmatter ────────────────────────────────────────────────────
17
+
18
+ /** Parse a YAML frontmatter block (`---\n...\n---`) from text. null if absent/invalid. */
19
+ export function parseFrontmatter(text) {
20
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
21
+ if (!m) return null;
22
+ try {
23
+ const parsed = parseYaml(m[1]);
24
+ return parsed && typeof parsed === 'object' ? parsed : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // ── Scan ────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Scan <dir>/.agents/skills/<name>/SKILL.md and <dir>/.agents/identities/<slug>/
34
+ * identity.yaml. Returns { skills, identities } catalog entries (metadata +
35
+ * pointers only — no file content). A skill with no frontmatter still indexes
36
+ * (name falls back to the dir). Identities index {slug, name} from identity.yaml.
37
+ */
38
+ export function scanRepoAgents(dir) {
39
+ const skills = [];
40
+ const identities = [];
41
+ const agentsDir = join(dir, '.agents');
42
+
43
+ const skillsDir = join(agentsDir, 'skills');
44
+ if (existsSync(skillsDir)) {
45
+ for (const name of readdirSafe(skillsDir)) {
46
+ const skillMd = join(skillsDir, name, 'SKILL.md');
47
+ if (!existsSync(skillMd)) continue;
48
+ const fm = parseFrontmatter(readFileSync(skillMd, 'utf8')) || {};
49
+ skills.push({
50
+ kind: 'skill',
51
+ name: String(fm.name || name),
52
+ slug: name,
53
+ description: fm.description != null ? String(fm.description) : '',
54
+ sourcePath: `.agents/skills/${name}`,
55
+ });
56
+ }
57
+ }
58
+
59
+ const identDir = join(agentsDir, 'identities');
60
+ if (existsSync(identDir)) {
61
+ for (const name of readdirSafe(identDir)) {
62
+ const idYaml = join(identDir, name, 'identity.yaml');
63
+ if (!existsSync(idYaml)) continue;
64
+ let fm = null;
65
+ try {
66
+ fm = parseYaml(readFileSync(idYaml, 'utf8')) || {};
67
+ } catch {
68
+ fm = {};
69
+ }
70
+ identities.push({
71
+ kind: 'identity',
72
+ name: String(fm.name || fm.slug || name),
73
+ slug: String(fm.slug || name),
74
+ description: null,
75
+ sourcePath: `.agents/identities/${name}`,
76
+ });
77
+ }
78
+ }
79
+
80
+ return { skills, identities };
81
+ }
82
+
83
+ function readdirSafe(dir) {
84
+ try {
85
+ return readdirSync(dir).filter((n) => !n.startsWith('.'));
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /** Best-effort default branch of a git checkout. 'main' if git is unavailable. */
92
+ export function detectDefaultBranch(dir) {
93
+ try {
94
+ const out = execSync('git rev-parse --abbrev-ref HEAD', {
95
+ cwd: dir,
96
+ stdio: ['ignore', 'pipe', 'ignore'],
97
+ encoding: 'utf8',
98
+ });
99
+ const branch = out.trim();
100
+ return branch || 'main';
101
+ } catch {
102
+ return 'main';
103
+ }
104
+ }
105
+
106
+ /** Remote origin URL of a git checkout, or null. */
107
+ export function detectRemoteUrl(dir) {
108
+ try {
109
+ const out = execSync('git config --get remote.origin.url', {
110
+ cwd: dir,
111
+ stdio: ['ignore', 'pipe', 'ignore'],
112
+ encoding: 'utf8',
113
+ });
114
+ const url = out.trim();
115
+ return url || null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /** True if the argument looks like a git URL (not a local path). */
122
+ export function isGitUrl(s) {
123
+ return /^(https?:|git@|ssh:\/\/|git:\/\/)/.test(s);
124
+ }
125
+
126
+ /** Clone a git URL into a fresh temp dir, returning { dir, cleanup }. */
127
+ export function cloneToTemp(url) {
128
+ const dir = mkdtempSync(join(tmpdir(), 'drafted-repo-'));
129
+ execSync(`git clone --depth 1 ${shquote(url)} ${shquote(dir)}`, {
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ encoding: 'utf8',
132
+ });
133
+ return {
134
+ dir,
135
+ cleanup: () => {
136
+ try { execSync(`rm -rf ${shquote(dir)}`, { stdio: 'ignore' }); } catch { /* best effort */ }
137
+ },
138
+ };
139
+ }
140
+
141
+ // Minimal shell quoting — clone targets are user-supplied URLs. We only pass
142
+ // them to git as a single argv via execSync through a shell string, so quote to
143
+ // avoid metacharacter injection. (The URL is also validated by git itself.)
144
+ function shquote(s) {
145
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.17.3",
3
+ "version": "1.17.5",
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": [
@@ -72,6 +72,7 @@
72
72
  "react-dom": "^18.3.1",
73
73
  "sharp": "^0.34.5",
74
74
  "ws": "^8.16.0",
75
+ "yaml": "^2.8.3",
75
76
  "zod": "^4.3.6"
76
77
  },
77
78
  "keywords": [
@@ -95,7 +96,6 @@
95
96
  "drizzle-kit": "^0.31.9",
96
97
  "tsx": "^4.19.0",
97
98
  "typescript": "^5.9.3",
98
- "vitest": "^3.2.4",
99
- "yaml": "^2.8.3"
99
+ "vitest": "^3.2.4"
100
100
  }
101
101
  }