claudenv 1.3.0 → 1.3.2

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.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * skills-registry.js — discover and install Claude skills from the
3
+ * awesome-claude-skills registry + a curated bundled catalog.
4
+ *
5
+ * The registry (https://github.com/ComposioHQ/awesome-claude-skills) is a plain
6
+ * markdown README, not a machine index, and its links are HETEROGENEOUS. Install
7
+ * classes (verified against live raw.githubusercontent.com endpoints 2026-06-08):
8
+ *
9
+ * repo-path : github.com/<o>/<r>/(tree|blob)/<branch>/<path>
10
+ * → https://raw.githubusercontent.com/<o>/<r>/<branch>/<path>/SKILL.md (reliable)
11
+ * in-repo : a README-relative "./<slug>/" link
12
+ * → resolved against the awesome repo (reliable)
13
+ * repo-root : github.com/<o>/<r> with no path
14
+ * → SKILL.md location unknown; best-effort probe, else guide
15
+ * bootstrap : not a copyable SKILL.md — installed by a shell command (kimi-webbridge)
16
+ * guide : non-fetchable (vendor dashboard, Composio platform connector) → show the URL
17
+ *
18
+ * SECURITY. installSkill() writes ONLY under ~/.claude/skills/<slug>/SKILL.md, never
19
+ * overwrites without force, validates that the fetched body is a real SKILL.md
20
+ * (frontmatter + name, size cap, not an HTML page), and NEVER executes fetched
21
+ * content. A fetched SKILL.md is auto-loaded, model-facing instruction text, so it
22
+ * is a prompt-injection surface: only CURATED (bundled) entries are safe to
23
+ * auto-equip; LIVE entries must be confirmed by the user (see the harness skill).
24
+ */
25
+
26
+ import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+ import {
29
+ claudeSkillsDir,
30
+ skillsRegistryCachePath,
31
+ parseFrontmatter,
32
+ } from './memory-paths.js';
33
+ import { BUNDLED_CATALOG, findBundled } from './bundled-catalog.js';
34
+
35
+ const AWESOME = { owner: 'ComposioHQ', repo: 'awesome-claude-skills', branch: 'master' };
36
+ const RAW = 'https://raw.githubusercontent.com';
37
+ const README_URL = `${RAW}/${AWESOME.owner}/${AWESOME.repo}/${AWESOME.branch}/README.md`;
38
+ const MAX_SKILL_BYTES = 256 * 1024;
39
+
40
+ // =============================================================
41
+ // Classification & resolution (pure)
42
+ // =============================================================
43
+
44
+ /** Classify a registry link into an install class from its URL shape alone. */
45
+ export function classifyUrl(url) {
46
+ const u = (url || '').trim();
47
+ if (!u) return 'guide';
48
+ // README-relative link → an in-repo skill folder of the awesome repo
49
+ if (!/^https?:\/\//i.test(u)) return 'in-repo';
50
+ const gh = /^https?:\/\/github\.com\/[^/]+\/[^/]+/i.test(u);
51
+ if (gh) {
52
+ return /\/(tree|blob)\/[^/]+\/.+/i.test(u) ? 'repo-path' : 'repo-root';
53
+ }
54
+ return 'guide';
55
+ }
56
+
57
+ /**
58
+ * Resolve an entry into something installable.
59
+ * Returns one of:
60
+ * { kind: 'bootstrap', command, url }
61
+ * { kind: 'fetch', class, candidates: [rawUrl...], url, uncertain? }
62
+ * { kind: 'guide', url, reason }
63
+ */
64
+ export function resolveSkillSource(entry) {
65
+ const url = (entry.url || '').trim();
66
+ // Strip query/fragment before constructing raw fetch URLs (display keeps `url`).
67
+ const u = url.replace(/[?#].*$/, '');
68
+ const cls = entry.install || classifyUrl(url);
69
+
70
+ if (cls === 'bootstrap' || entry.bootstrap) {
71
+ return { kind: 'bootstrap', command: entry.bootstrap, url };
72
+ }
73
+
74
+ if (cls === 'in-repo') {
75
+ const path = u.replace(/^\.?\/+/, '').replace(/\/+$/, '');
76
+ if (!path) return { kind: 'guide', url, reason: 'empty in-repo path' };
77
+ return {
78
+ kind: 'fetch',
79
+ class: 'in-repo',
80
+ url,
81
+ candidates: [`${RAW}/${AWESOME.owner}/${AWESOME.repo}/${AWESOME.branch}/${path}/SKILL.md`],
82
+ };
83
+ }
84
+
85
+ if (cls === 'repo-path') {
86
+ const m = /github\.com\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)\/(.+?)\/?$/i.exec(u);
87
+ if (m) {
88
+ const [, owner, repo, branch, path] = m;
89
+ return {
90
+ kind: 'fetch',
91
+ class: 'repo-path',
92
+ url,
93
+ candidates: [`${RAW}/${owner}/${repo}/${branch}/${path}/SKILL.md`],
94
+ };
95
+ }
96
+ }
97
+
98
+ if (cls === 'repo-root') {
99
+ const m = /github\.com\/([^/]+)\/([^/]+?)\/?$/i.exec(u);
100
+ if (m) {
101
+ const [, owner, repo] = m;
102
+ const candidates = [];
103
+ for (const b of ['main', 'master']) {
104
+ candidates.push(`${RAW}/${owner}/${repo}/${b}/SKILL.md`);
105
+ candidates.push(`${RAW}/${owner}/${repo}/${b}/skills/${repo}/SKILL.md`);
106
+ candidates.push(`${RAW}/${owner}/${repo}/${b}/${repo}/SKILL.md`);
107
+ }
108
+ return { kind: 'fetch', class: 'repo-root', url, candidates, uncertain: true };
109
+ }
110
+ }
111
+
112
+ return { kind: 'guide', url, reason: 'not a fetchable SKILL.md (vendor/platform link)' };
113
+ }
114
+
115
+ // Terminal path segments that are too generic to be a distinctive slug — for
116
+ // these we prefer the descriptive link text (e.g. ".../ai-skills/tree/main/skills").
117
+ const GENERIC_SEGMENTS = new Set([
118
+ 'skills', 'skill', 'src', 'main', 'master', 'plugin', 'plugins',
119
+ 'package', 'packages', 'tree', 'blob', 'docs', 'examples',
120
+ ]);
121
+
122
+ /** Derive a filesystem-safe slug from a registry entry's name/url. */
123
+ export function slugify(name, url) {
124
+ const cleaned = (url || '')
125
+ .replace(/[?#].*$/, '') // drop query/fragment
126
+ .replace(/\/(tree|blob)\/[^/]+\//i, '/') // drop the /tree|blob/<branch>/ segment
127
+ .replace(/\/+$/, '');
128
+ const last = /\/([^/]+)$/.exec(cleaned);
129
+ let base = (last ? last[1] : cleaned).toLowerCase();
130
+ base = base.replace(/\.git$/, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
131
+ const nameSlug = (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
132
+ if (base && /^[a-z0-9]/.test(base) && !GENERIC_SEGMENTS.has(base)) return base;
133
+ return nameSlug || base || 'skill';
134
+ }
135
+
136
+ // =============================================================
137
+ // README parsing (pure)
138
+ // =============================================================
139
+
140
+ /**
141
+ * Parse the awesome-claude-skills README markdown into entries. Only the
142
+ * "## Skills" section is scanned; "### <category>" headings group entries;
143
+ * items look like `- [Name](url) - description *By [@x](...)*`.
144
+ */
145
+ export function parseRegistry(markdown) {
146
+ const entries = [];
147
+ let category = null;
148
+ let inSkills = false;
149
+
150
+ for (const raw of String(markdown || '').split('\n')) {
151
+ const line = raw.replace(/\s+$/, '');
152
+
153
+ const h2 = /^##\s+(.+)$/.exec(line);
154
+ if (h2) {
155
+ inSkills = /^skills\b/i.test(h2[1].trim());
156
+ category = null;
157
+ continue;
158
+ }
159
+ if (!inSkills) continue;
160
+
161
+ const h3 = /^###\s+(.+)$/.exec(line);
162
+ if (h3) {
163
+ category = h3[1].trim();
164
+ continue;
165
+ }
166
+
167
+ const item = /^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*[-–—:]*\s*(.*)$/.exec(line);
168
+ if (!item) continue;
169
+
170
+ const name = item[1].trim();
171
+ const url = item[2].trim();
172
+ let description = item[3].trim();
173
+ // strip a trailing "*By [@author](...)*" attribution
174
+ description = description.replace(/\*By\s+\[[^\]]*\]\([^)]*\)\*\s*$/i, '').trim();
175
+ description = description.replace(/\s*\*By\b[^*]*\*\s*$/i, '').trim();
176
+
177
+ if (!name || !url) continue;
178
+ entries.push({
179
+ name,
180
+ slug: slugify(name, url),
181
+ url,
182
+ description,
183
+ category,
184
+ install: classifyUrl(url),
185
+ curated: false,
186
+ });
187
+ }
188
+ return entries;
189
+ }
190
+
191
+ /** Rank catalog entries against a free-text query (curated entries get a nudge). */
192
+ export function searchCatalog(entries, query) {
193
+ if (!query || !query.trim()) {
194
+ return [...entries].sort((a, b) => Number(b.curated) - Number(a.curated));
195
+ }
196
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
197
+ const scored = [];
198
+ for (const e of entries) {
199
+ const slug = (e.slug || '').toLowerCase();
200
+ const name = (e.name || '').toLowerCase();
201
+ const hay = `${name} ${slug} ${e.description || ''} ${e.category || ''}`.toLowerCase();
202
+ let score = 0;
203
+ let matchedAll = true;
204
+ for (const t of terms) {
205
+ if (!hay.includes(t)) {
206
+ matchedAll = false;
207
+ break;
208
+ }
209
+ if (slug === t) score += 6;
210
+ score += name.includes(t) ? 3 : 1;
211
+ }
212
+ if (!matchedAll) continue;
213
+ if (e.curated) score += 2;
214
+ scored.push({ e, score });
215
+ }
216
+ scored.sort((a, b) => b.score - a.score);
217
+ return scored.map((x) => x.e);
218
+ }
219
+
220
+ /** Merge curated (authoritative) + live entries; curated wins on slug collision. */
221
+ export function mergeCatalog(curated, live) {
222
+ const bySlug = new Map();
223
+ for (const e of live || []) bySlug.set(e.slug, e);
224
+ for (const e of curated || []) bySlug.set(e.slug, e); // curated overrides
225
+ return [...bySlug.values()];
226
+ }
227
+
228
+ // =============================================================
229
+ // Fetch / cache / install (effectful — fetch is injectable for tests)
230
+ // =============================================================
231
+
232
+ async function fetchText(url, fetchImpl) {
233
+ const f = fetchImpl || globalThis.fetch;
234
+ if (!f) throw new Error('no fetch available — Node >= 18 or pass fetchImpl');
235
+ let res;
236
+ try {
237
+ res = await f(url, { headers: { 'User-Agent': 'claudenv-skills' }, redirect: 'follow' });
238
+ } catch {
239
+ return null;
240
+ }
241
+ if (!res || !res.ok) return null;
242
+ return await res.text();
243
+ }
244
+
245
+ /** Validate that a fetched body really is a SKILL.md (cheap safety gate). */
246
+ export function validateSkillBody(text) {
247
+ if (!text || typeof text !== 'string') return { ok: false, reason: 'empty response' };
248
+ if (Buffer.byteLength(text, 'utf-8') > MAX_SKILL_BYTES) {
249
+ return { ok: false, reason: 'SKILL.md too large (>256KB) — fetch manually' };
250
+ }
251
+ if (/^\s*<(!doctype|html|head|body)\b/i.test(text)) {
252
+ return { ok: false, reason: 'response looks like an HTML page, not a SKILL.md' };
253
+ }
254
+ const fm = parseFrontmatter(text);
255
+ if (!fm || !fm.name) return { ok: false, reason: 'no SKILL.md frontmatter (missing name:)' };
256
+ return { ok: true, frontmatter: fm };
257
+ }
258
+
259
+ /** Fetch + parse the live registry README. Throws only if README itself is unreachable. */
260
+ export async function fetchRegistry(fetchImpl) {
261
+ const md = await fetchText(README_URL, fetchImpl);
262
+ if (!md) throw new Error(`could not fetch registry README (${README_URL})`);
263
+ return parseRegistry(md);
264
+ }
265
+
266
+ async function readCache() {
267
+ try {
268
+ return JSON.parse(await readFile(skillsRegistryCachePath(), 'utf-8'));
269
+ } catch {
270
+ return [];
271
+ }
272
+ }
273
+
274
+ export async function writeCache(entries) {
275
+ const p = skillsRegistryCachePath();
276
+ await mkdir(join(p, '..'), { recursive: true });
277
+ await writeFile(p, JSON.stringify(entries, null, 2), 'utf-8');
278
+ }
279
+
280
+ /** Fetch the live registry and persist it to the cache. Returns the entries. */
281
+ export async function refreshCatalog(fetchImpl) {
282
+ const entries = await fetchRegistry(fetchImpl);
283
+ await writeCache(entries);
284
+ return entries;
285
+ }
286
+
287
+ /**
288
+ * Synthesize a catalog entry from a bare URL (github tree/blob/root or a
289
+ * README-relative path), so `skills add <url>` works. Always non-curated.
290
+ */
291
+ export function makeEntryFromUrl(url) {
292
+ const u = (url || '').trim();
293
+ return {
294
+ name: u,
295
+ slug: slugify(u, u),
296
+ url: u,
297
+ description: '',
298
+ category: null,
299
+ install: classifyUrl(u),
300
+ curated: false,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Build the working catalog: bundled (curated) + live (cached, or refreshed).
306
+ * Offline-first — with no network and no cache you still get the curated set.
307
+ */
308
+ export async function loadCatalog({ fetchImpl, refresh = false } = {}) {
309
+ const curated = BUNDLED_CATALOG.map((e) => ({ ...e, curated: true }));
310
+ let live = [];
311
+ if (refresh) {
312
+ try {
313
+ live = await refreshCatalog(fetchImpl);
314
+ } catch {
315
+ live = await readCache();
316
+ }
317
+ } else {
318
+ live = await readCache();
319
+ }
320
+ return mergeCatalog(curated, live);
321
+ }
322
+
323
+ /** List skills currently installed under ~/.claude/skills/. */
324
+ export async function listInstalledSkills(claudeHome) {
325
+ const dir = claudeHome ? join(claudeHome, 'skills') : claudeSkillsDir();
326
+ let entries;
327
+ try {
328
+ entries = await readdir(dir, { withFileTypes: true });
329
+ } catch {
330
+ return [];
331
+ }
332
+ const out = [];
333
+ for (const ent of entries) {
334
+ if (!ent.isDirectory()) continue;
335
+ let frontmatter = null;
336
+ try {
337
+ frontmatter = parseFrontmatter(await readFile(join(dir, ent.name, 'SKILL.md'), 'utf-8'));
338
+ } catch {
339
+ /* directory without a SKILL.md — skip metadata */
340
+ }
341
+ out.push({
342
+ slug: ent.name,
343
+ name: (frontmatter && frontmatter.name) || ent.name,
344
+ description: (frontmatter && frontmatter.description) || '',
345
+ hasSkillMd: !!frontmatter,
346
+ });
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Find a catalog entry by slug or name (case-insensitive). */
352
+ export function findEntry(catalog, nameOrSlug) {
353
+ if (!nameOrSlug) return null;
354
+ const q = String(nameOrSlug).toLowerCase();
355
+ return (
356
+ catalog.find((e) => e.slug.toLowerCase() === q) ||
357
+ catalog.find((e) => (e.name || '').toLowerCase() === q) ||
358
+ catalog.find((e) => e.slug.toLowerCase().includes(q)) ||
359
+ findBundled(nameOrSlug) ||
360
+ null
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Install one skill. Returns a result describing what happened:
366
+ * { action: 'installed', slug, path, source, curated, frontmatter }
367
+ * { action: 'exists', slug, path }
368
+ * { action: 'needs-confirm', slug, url } — live (untrusted) entry, confirmLive not set
369
+ * { action: 'bootstrap', slug, command, url } — caller decides whether to run it
370
+ * { action: 'guide', slug, url, reason } — not file-copyable
371
+ * { action: 'invalid', slug, url, reason } — fetched body failed validation
372
+ *
373
+ * TRUST GATE (enforced in code, not just docs): a live (non-curated) entry is NOT
374
+ * fetched or written unless `confirmLive` is true. `claudenv loop` never passes it,
375
+ * so the loop is physically unable to install a live skill — only the curated
376
+ * allowlist can auto-equip. A fetched SKILL.md is auto-loaded model-facing text.
377
+ *
378
+ * Never throws on network/validation failures — degrades to 'guide'/'invalid'.
379
+ */
380
+ export async function installSkill(entry, { fetchImpl, claudeHome, force = false, confirmLive = false } = {}) {
381
+ const slug = entry.slug || slugify(entry.name, entry.url);
382
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) {
383
+ throw new Error(`invalid skill slug "${slug}"`);
384
+ }
385
+
386
+ const src = resolveSkillSource(entry);
387
+ if (src.kind === 'bootstrap') {
388
+ return { action: 'bootstrap', slug, command: src.command, url: src.url };
389
+ }
390
+ if (src.kind === 'guide') {
391
+ return { action: 'guide', slug, url: src.url, reason: src.reason };
392
+ }
393
+
394
+ // Trust gate: never fetch/write an untrusted (non-curated) skill without
395
+ // explicit confirmation. This is the code-level enforcement of the boundary
396
+ // that the docs and the loop fragment describe.
397
+ if (entry.curated !== true && !confirmLive) {
398
+ return { action: 'needs-confirm', slug, url: src.url || entry.url };
399
+ }
400
+
401
+ const skillsHome = claudeHome ? join(claudeHome, 'skills') : claudeSkillsDir();
402
+ const destDir = join(skillsHome, slug);
403
+ const destFile = join(destDir, 'SKILL.md');
404
+
405
+ if (!force) {
406
+ try {
407
+ await stat(destFile);
408
+ return { action: 'exists', slug, path: destFile };
409
+ } catch {
410
+ /* not installed yet — proceed */
411
+ }
412
+ }
413
+
414
+ let body = null;
415
+ let usedUrl = null;
416
+ for (const candidate of src.candidates) {
417
+ body = await fetchText(candidate, fetchImpl);
418
+ if (body) {
419
+ usedUrl = candidate;
420
+ break;
421
+ }
422
+ }
423
+ if (!body) {
424
+ return {
425
+ action: 'guide',
426
+ slug,
427
+ url: src.url,
428
+ reason: 'no SKILL.md found at the expected location — open the link and copy it manually',
429
+ };
430
+ }
431
+
432
+ const valid = validateSkillBody(body);
433
+ if (!valid.ok) {
434
+ return { action: 'invalid', slug, url: usedUrl, reason: valid.reason };
435
+ }
436
+
437
+ await mkdir(destDir, { recursive: true });
438
+ await writeFile(destFile, body, 'utf-8');
439
+ return {
440
+ action: 'installed',
441
+ slug,
442
+ path: destFile,
443
+ source: usedUrl,
444
+ curated: !!entry.curated,
445
+ frontmatter: valid.frontmatter,
446
+ };
447
+ }
package/src/sources.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * CLI: `claudenv source list|show`
3
+ *
4
+ * Connectors are knowledge records (params + provenance) stored in the ACTIVE
5
+ * workspace: ~/.claudenv/workspaces/<id>/memories/connectors/<name>.md.
6
+ *
7
+ * Records hold connection metadata and secret_refs (env-var NAMES) only.
8
+ * Actual secret values live in <project>/.env.local and must never appear here.
9
+ * The skill `source-connector` is the primary author; CLI is for listing and
10
+ * for the doctor leak-lint.
11
+ */
12
+
13
+ import { readFile, readdir } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { workspaceConnectorsDir, activeWorkspaceId, parseFrontmatter } from './memory-paths.js';
16
+
17
+ export async function listConnectors() {
18
+ const id = activeWorkspaceId();
19
+ if (!id) return { workspace: null, connectors: [] };
20
+ let files;
21
+ try {
22
+ files = (await readdir(workspaceConnectorsDir(id))).filter((f) => f.endsWith('.md'));
23
+ } catch {
24
+ return { workspace: id, connectors: [] };
25
+ }
26
+ const connectors = [];
27
+ for (const f of files) {
28
+ let fm = null;
29
+ try {
30
+ fm = parseFrontmatter(await readFile(join(workspaceConnectorsDir(id), f), 'utf-8'));
31
+ } catch { /* skip unreadable */ }
32
+ connectors.push({
33
+ name: (fm && fm.name) || f.replace(/\.md$/, ''),
34
+ type: (fm && fm.type) || '?',
35
+ status: (fm && fm.status) || '?',
36
+ host: (fm && fm.host) || '',
37
+ });
38
+ }
39
+ return { workspace: id, connectors };
40
+ }
41
+
42
+ export async function showConnector(name) {
43
+ const id = activeWorkspaceId();
44
+ if (!id) return null;
45
+ try {
46
+ return await readFile(join(workspaceConnectorsDir(id), `${name}.md`), 'utf-8');
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Heuristic leak scan for the doctor. Flags lines that look like a secret VALUE
54
+ * rather than a reference. Connector records should only carry env-var names.
55
+ *
56
+ * Returns an array of { line, reason } findings (empty = clean).
57
+ */
58
+ export function scanForSecretLeaks(text) {
59
+ const findings = [];
60
+ const lines = text.split('\n');
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const line = lines[i];
63
+ // secret_refs хранит ИМЕНА env-переменных по определению - не сканируем
64
+ if (/secret_refs/i.test(line)) continue;
65
+ // `password: <value>` / `token: <value>` with a non-placeholder, non-env value
66
+ const m = /\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)\b\s*[:=]\s*(\S+)/i.exec(line);
67
+ if (m) {
68
+ // снять кавычки и хвостовую пунктуацию inline-структур ({ } , ; )
69
+ const val = m[2].replace(/['"]/g, '').replace(/[},;)]+$/, '');
70
+ const placeholder = /^(<.*>|\.\.\.|x+|\*+|null|none|env:|\$\{|secret_refs)/i.test(val);
71
+ const looksEnvName = /^[A-Z][A-Z0-9_]*$/.test(val) || /_(env|token|login|password)$/i.test(val);
72
+ if (!placeholder && !looksEnvName && val.length >= 6) {
73
+ findings.push({ line: i + 1, reason: `похоже на значение секрета: ${m[1]}` });
74
+ }
75
+ }
76
+ }
77
+ return findings;
78
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * CLI: `claudenv workspace add|list|use|show`
3
+ *
4
+ * Workspaces are isolated per-company/context memory spaces under
5
+ * ~/.claudenv/workspaces/<id>/. Only the ACTIVE workspace is ever loaded -
6
+ * this is the isolation barrier that prevents access/context from one company
7
+ * leaking into another project on the same device.
8
+ *
9
+ * Secrets NEVER live here - they belong in <project>/.env.local (gitignored).
10
+ * Workspace memory holds connector metadata + provenance only.
11
+ */
12
+
13
+ import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
14
+ import {
15
+ workspacesDir,
16
+ workspaceDir,
17
+ workspaceManifestPath,
18
+ workspaceConnectorsDir,
19
+ workspaceContextDir,
20
+ activeWorkspaceFile,
21
+ activeWorkspaceId,
22
+ } from './memory-paths.js';
23
+
24
+ const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
25
+
26
+ export function validateId(id) {
27
+ if (!id || !SLUG_RE.test(id)) {
28
+ throw new Error(`Invalid workspace id "${id}" - use lowercase letters, digits, - or _`);
29
+ }
30
+ return id;
31
+ }
32
+
33
+ /**
34
+ * Render a flat workspace.yaml. Minimal by design (human-editable), like canon.
35
+ */
36
+ function renderManifest({ name, description, paths }) {
37
+ const lines = [];
38
+ lines.push(`name: ${name || ''}`);
39
+ lines.push(`description: ${description || ''}`);
40
+ lines.push('paths:');
41
+ for (const p of paths || []) lines.push(` - ${p}`);
42
+ return lines.join('\n') + '\n';
43
+ }
44
+
45
+ /** Tolerant parser for the flat workspace.yaml above. */
46
+ function parseManifest(text) {
47
+ const out = { name: '', description: '', paths: [] };
48
+ let inPaths = false;
49
+ for (const raw of text.split('\n')) {
50
+ const line = raw.replace(/\s+$/, '');
51
+ if (!line || line.startsWith('#')) continue;
52
+ const kv = /^([a-zA-Z_]+):\s*(.*)$/.exec(line);
53
+ if (kv && kv[1] === 'paths') { inPaths = true; continue; }
54
+ if (kv) {
55
+ inPaths = false;
56
+ if (kv[1] === 'name') out.name = kv[2];
57
+ else if (kv[1] === 'description') out.description = kv[2];
58
+ continue;
59
+ }
60
+ const item = /^\s+-\s+(.*)$/.exec(line);
61
+ if (item && inPaths) out.paths.push(item[1].trim());
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export async function createWorkspace(id, { name, description, paths } = {}) {
67
+ validateId(id);
68
+ const dir = workspaceDir(id);
69
+ try {
70
+ await stat(dir);
71
+ throw new Error(`Workspace "${id}" already exists`);
72
+ } catch (e) {
73
+ if (e.code !== 'ENOENT') throw e;
74
+ }
75
+ await mkdir(workspaceConnectorsDir(id), { recursive: true });
76
+ await mkdir(workspaceContextDir(id), { recursive: true });
77
+ await writeFile(
78
+ workspaceManifestPath(id),
79
+ renderManifest({ name: name || id, description, paths }),
80
+ 'utf-8'
81
+ );
82
+ // секреты сюда не кладём - явный gitignore на случай, если папку положат в git
83
+ await writeFile(workspaceDir(id) + '/.gitignore', '.env\n.env.local\n*.secret\n', 'utf-8');
84
+ return dir;
85
+ }
86
+
87
+ export async function listWorkspaces() {
88
+ let entries;
89
+ try {
90
+ entries = await readdir(workspacesDir(), { withFileTypes: true });
91
+ } catch {
92
+ return [];
93
+ }
94
+ const active = activeWorkspaceId();
95
+ const result = [];
96
+ for (const ent of entries) {
97
+ if (!ent.isDirectory()) continue;
98
+ let manifest = { name: ent.name, description: '', paths: [] };
99
+ try {
100
+ manifest = parseManifest(await readFile(workspaceManifestPath(ent.name), 'utf-8'));
101
+ } catch { /* нет манифеста - покажем по id */ }
102
+ result.push({ id: ent.name, active: ent.name === active, ...manifest });
103
+ }
104
+ return result;
105
+ }
106
+
107
+ export async function useWorkspace(id) {
108
+ validateId(id);
109
+ try {
110
+ await stat(workspaceDir(id));
111
+ } catch {
112
+ throw new Error(`Workspace "${id}" does not exist - create it with 'claudenv workspace add ${id}'`);
113
+ }
114
+ await writeFile(activeWorkspaceFile(), id + '\n', 'utf-8');
115
+ return id;
116
+ }
117
+
118
+ export async function showActive() {
119
+ const id = activeWorkspaceId();
120
+ if (!id) return null;
121
+ let manifest = { name: id, description: '', paths: [] };
122
+ try {
123
+ manifest = parseManifest(await readFile(workspaceManifestPath(id), 'utf-8'));
124
+ } catch { /* активный указан, но манифеста нет */ }
125
+ const source = process.env.CLAUDENV_WORKSPACE ? 'env CLAUDENV_WORKSPACE' : 'active-workspace file';
126
+ return { id, source, ...manifest };
127
+ }
128
+
129
+ export { parseManifest };