codemini-cli 0.5.8 → 0.5.10

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.
@@ -9,58 +9,107 @@ import { createChatRuntime } from '../src/core/chat-runtime.js';
9
9
  import { createSession, loadSession, listSessions, resolveSession, deleteSession } from '../src/core/session-store.js';
10
10
  import { buildDefaultSystemPrompt } from '../src/core/default-system-prompt.js';
11
11
  import { RuntimeBridge } from './lib/runtime-bridge.js';
12
- import { listSkillEntries } from '../src/commands/skill.js';
13
- import { readSkillRegistry, writeSkillRegistry, upsertSkillRegistryEntry } from '../src/core/skill-registry.js';
14
- import { getReplyLanguage } from '../src/core/reply-language.js';
15
- import { getSkillsDir, getBaseConfigDir } from '../src/core/paths.js';
16
- import { VERSION } from '../src/core/version.js';
12
+ import { listSkillEntries } from '../src/commands/skill.js';
13
+ import { readSkillRegistry, writeSkillRegistry } from '../src/core/skill-registry.js';
14
+ import { getReplyLanguage } from '../src/core/reply-language.js';
15
+ import { getBaseConfigDir, getProjectSkillsDir } from '../src/core/paths.js';
16
+ import { VERSION } from '../src/core/version.js';
17
17
 
18
18
  const GENERAL_PROJECT_DIR = (() => {
19
19
  const base = getBaseConfigDir();
20
20
  return path.join(base, 'workspace');
21
21
  })();
22
22
 
23
- async function listProjectRoots() {
24
- if (process.platform === 'win32') {
25
- const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
26
- const roots = [];
27
- await Promise.all(letters.map(async (letter) => {
28
- const drivePath = `${letter}:\\`;
29
- try {
30
- await fs.access(drivePath);
31
- roots.push({ name: `${letter}:`, path: drivePath, isGit: false, isDrive: true });
32
- } catch {}
33
- }));
34
- return roots.sort((a, b) => a.name.localeCompare(b.name));
23
+ const SKILL_CATALOG_FILE = 'codemini.skills.json';
24
+ const SKILL_MODES = new Set(['always', 'auto_attach', 'agent_requested', 'manual']);
25
+
26
+ function normalizeSkillMetadataPatch(input = {}) {
27
+ const out = {};
28
+ if (typeof input.description === 'string') out.description = input.description.trim();
29
+ if (typeof input.mode === 'string' && SKILL_MODES.has(input.mode)) out.mode = input.mode;
30
+ if (input.enabled !== undefined) out.enabled = input.enabled !== false;
31
+ if (input.priority !== undefined) {
32
+ const priority = Number(input.priority);
33
+ if (Number.isFinite(priority)) out.priority = Math.max(0, Math.min(100, Math.round(priority)));
34
+ }
35
+ if (Array.isArray(input.triggers)) {
36
+ out.triggers = input.triggers.map((item) => String(item || '').trim()).filter(Boolean);
37
+ } else if (typeof input.triggers === 'string') {
38
+ out.triggers = input.triggers.split(',').map((item) => item.trim()).filter(Boolean);
35
39
  }
40
+ return out;
41
+ }
36
42
 
37
- const candidates = [
38
- { name: '/', path: path.resolve('/') },
39
- { name: 'Home', path: process.env.HOME || process.env.USERPROFILE || '' },
40
- { name: 'Current', path: process.cwd() },
41
- ];
42
- const seen = new Set();
43
- const roots = [];
44
- for (const candidate of candidates) {
45
- if (!candidate.path) continue;
46
- const resolved = path.resolve(candidate.path);
47
- if (seen.has(resolved)) continue;
48
- try {
49
- const stat = await fs.stat(resolved);
50
- if (!stat.isDirectory()) continue;
51
- seen.add(resolved);
52
- roots.push({ name: candidate.name, path: resolved, isGit: false, isDrive: false });
53
- } catch {}
43
+ async function readProjectSkillCatalog(projectDir) {
44
+ const catalogPath = path.join(getProjectSkillsDir(projectDir), SKILL_CATALOG_FILE);
45
+ try {
46
+ const parsed = JSON.parse(await fs.readFile(catalogPath, 'utf8'));
47
+ return parsed && typeof parsed === 'object' ? parsed : { version: 1, skills: {} };
48
+ } catch {
49
+ return { version: 1, skills: {} };
54
50
  }
55
- return roots;
56
51
  }
57
52
 
58
- function isGeneralProjectDir(value) {
59
- if (!value) return false;
60
- return path.resolve(value) === path.resolve(GENERAL_PROJECT_DIR);
53
+ async function writeProjectSkillCatalog(projectDir, catalog) {
54
+ const catalogPath = path.join(getProjectSkillsDir(projectDir), SKILL_CATALOG_FILE);
55
+ await fs.mkdir(path.dirname(catalogPath), { recursive: true });
56
+ const next = {
57
+ version: 1,
58
+ skills: catalog?.skills && typeof catalog.skills === 'object' ? catalog.skills : {}
59
+ };
60
+ await fs.writeFile(catalogPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
61
61
  }
62
62
 
63
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
63
+ async function upsertProjectSkillMetadata(projectDir, name, patch) {
64
+ const catalog = await readProjectSkillCatalog(projectDir);
65
+ catalog.skills = catalog.skills || {};
66
+ const prior = catalog.skills[name] && typeof catalog.skills[name] === 'object' ? catalog.skills[name] : {};
67
+ catalog.skills[name] = { ...prior, ...normalizeSkillMetadataPatch(patch) };
68
+ await writeProjectSkillCatalog(projectDir, catalog);
69
+ return catalog.skills[name];
70
+ }
71
+
72
+ async function listProjectRoots() {
73
+ if (process.platform === 'win32') {
74
+ const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
75
+ const roots = [];
76
+ await Promise.all(letters.map(async (letter) => {
77
+ const drivePath = `${letter}:\\`;
78
+ try {
79
+ await fs.access(drivePath);
80
+ roots.push({ name: `${letter}:`, path: drivePath, isGit: false, isDrive: true });
81
+ } catch {}
82
+ }));
83
+ return roots.sort((a, b) => a.name.localeCompare(b.name));
84
+ }
85
+
86
+ const candidates = [
87
+ { name: '/', path: path.resolve('/') },
88
+ { name: 'Home', path: process.env.HOME || process.env.USERPROFILE || '' },
89
+ { name: 'Current', path: process.cwd() },
90
+ ];
91
+ const seen = new Set();
92
+ const roots = [];
93
+ for (const candidate of candidates) {
94
+ if (!candidate.path) continue;
95
+ const resolved = path.resolve(candidate.path);
96
+ if (seen.has(resolved)) continue;
97
+ try {
98
+ const stat = await fs.stat(resolved);
99
+ if (!stat.isDirectory()) continue;
100
+ seen.add(resolved);
101
+ roots.push({ name: candidate.name, path: resolved, isGit: false, isDrive: false });
102
+ } catch {}
103
+ }
104
+ return roots;
105
+ }
106
+
107
+ function isGeneralProjectDir(value) {
108
+ if (!value) return false;
109
+ return path.resolve(value) === path.resolve(GENERAL_PROJECT_DIR);
110
+ }
111
+
112
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
64
113
  const CLIENT_SOURCE_DIR = path.join(__dirname, 'client');
65
114
  let CLIENT_DIR = CLIENT_SOURCE_DIR;
66
115
  try {
@@ -69,33 +118,33 @@ try {
69
118
  if (stat.isDirectory()) CLIENT_DIR = distDir;
70
119
  } catch {}
71
120
 
72
- const MIME_TYPES = {
121
+ const MIME_TYPES = {
73
122
  '.html': 'text/html; charset=utf-8',
74
123
  '.css': 'text/css; charset=utf-8',
75
124
  '.js': 'text/javascript; charset=utf-8',
76
125
  '.json': 'application/json',
77
126
  '.svg': 'image/svg+xml',
78
127
  '.png': 'image/png',
79
- '.ico': 'image/x-icon'
80
- };
81
-
82
- const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:8000/v1';
83
-
84
- function normalizeBaseUrl(value) {
85
- return String(value || '').trim().replace(/\/+$/, '');
86
- }
87
-
88
- function getConfigStatus(config) {
89
- const baseUrl = normalizeBaseUrl(config?.gateway?.base_url);
90
- const apiKey = String(config?.gateway?.api_key || '').trim();
91
- const setupRequired = !baseUrl || (baseUrl === DEFAULT_GATEWAY_BASE_URL && !apiKey);
92
- return {
93
- setupRequired,
94
- baseUrl,
95
- hasApiKey: !!apiKey,
96
- reason: setupRequired ? 'gateway_not_configured' : ''
97
- };
98
- }
128
+ '.ico': 'image/x-icon'
129
+ };
130
+
131
+ const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:8000/v1';
132
+
133
+ function normalizeBaseUrl(value) {
134
+ return String(value || '').trim().replace(/\/+$/, '');
135
+ }
136
+
137
+ function getConfigStatus(config) {
138
+ const baseUrl = normalizeBaseUrl(config?.gateway?.base_url);
139
+ const apiKey = String(config?.gateway?.api_key || '').trim();
140
+ const setupRequired = !baseUrl || (baseUrl === DEFAULT_GATEWAY_BASE_URL && !apiKey);
141
+ return {
142
+ setupRequired,
143
+ baseUrl,
144
+ hasApiKey: !!apiKey,
145
+ reason: setupRequired ? 'gateway_not_configured' : ''
146
+ };
147
+ }
99
148
 
100
149
  function parseArgs(argv) {
101
150
  const parsed = { port: 3210, session: undefined, model: undefined, project: undefined, open: true };
@@ -205,10 +254,14 @@ function collectSessionPathHints(session) {
205
254
  return hints;
206
255
  }
207
256
 
208
- async function existingDirectoryForHint(rawHint) {
209
- let candidate = normalizeProjectPath(rawHint);
210
- if (!candidate) return '';
211
- candidate = candidate.replace(/[),\].。;;:]+$/g, '');
257
+ async function existingDirectoryForHint(rawHint) {
258
+ let candidate = normalizeProjectPath(rawHint);
259
+ if (!candidate) return '';
260
+ const configRoot = path.resolve(getBaseConfigDir());
261
+ const candidateLower = path.resolve(candidate).toLowerCase();
262
+ const configRootLower = configRoot.toLowerCase();
263
+ if (candidateLower === configRootLower || candidateLower.startsWith(`${configRootLower}${path.sep}`)) return '';
264
+ candidate = candidate.replace(/[),\].。;;:]+$/g, '');
212
265
  for (let i = 0; i < 8 && candidate && candidate !== path.dirname(candidate); i += 1) {
213
266
  try {
214
267
  const stat = await fs.stat(candidate);
@@ -295,7 +348,7 @@ async function buildRuntimeForSession({ sessionId, model, projectDir }) {
295
348
  model: model || config.model?.name,
296
349
  systemPrompt
297
350
  });
298
- return { runtime, config, session, cwd: process.cwd(), isGeneral: isGeneralProjectDir(process.cwd()) };
351
+ return { runtime, config, session, cwd: process.cwd(), isGeneral: isGeneralProjectDir(process.cwd()) };
299
352
  }
300
353
 
301
354
  async function main() {
@@ -352,25 +405,25 @@ async function main() {
352
405
  return;
353
406
  }
354
407
 
355
- // ── Submit / Abort / Approval ──
356
- if (req.method === 'POST' && url.pathname === '/api/submit') {
357
- const { line, readOnlyCodeWiki } = await readBody(req);
358
- if (!line || typeof line !== 'string') { jsonResponse(res, { error: true, message: 'Missing "line" field' }, 400); return; }
359
- const currentConfig = await loadConfig();
360
- const configStatus = getConfigStatus(currentConfig);
361
- if (configStatus.setupRequired) {
362
- jsonResponse(res, {
363
- error: true,
364
- code: 'CONFIG_REQUIRED',
365
- message: 'Gateway is not configured. Open Settings and set the API Base URL and API Key.',
366
- configStatus
367
- }, 409);
368
- return;
369
- }
370
- const result = bridge.handleSubmit(line, { readOnlyCodeWiki: readOnlyCodeWiki === true });
371
- jsonResponse(res, result);
372
- return;
373
- }
408
+ // ── Submit / Abort / Approval ──
409
+ if (req.method === 'POST' && url.pathname === '/api/submit') {
410
+ const { line, readOnlyCodeWiki } = await readBody(req);
411
+ if (!line || typeof line !== 'string') { jsonResponse(res, { error: true, message: 'Missing "line" field' }, 400); return; }
412
+ const currentConfig = await loadConfig();
413
+ const configStatus = getConfigStatus(currentConfig);
414
+ if (configStatus.setupRequired) {
415
+ jsonResponse(res, {
416
+ error: true,
417
+ code: 'CONFIG_REQUIRED',
418
+ message: 'Gateway is not configured. Open Settings and set the API Base URL and API Key.',
419
+ configStatus
420
+ }, 409);
421
+ return;
422
+ }
423
+ const result = bridge.handleSubmit(line, { readOnlyCodeWiki: readOnlyCodeWiki === true });
424
+ jsonResponse(res, result);
425
+ return;
426
+ }
374
427
  if (req.method === 'POST' && url.pathname === '/api/abort') {
375
428
  bridge.handleAbort();
376
429
  jsonResponse(res, { ok: true });
@@ -402,7 +455,7 @@ async function main() {
402
455
  try {
403
456
  latest = execSync('npm view codemini-cli version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
404
457
  } catch {}
405
- jsonResponse(res, { current: VERSION, latest });
458
+ jsonResponse(res, { current: VERSION, latest });
406
459
  return;
407
460
  }
408
461
  if (req.method === 'POST' && url.pathname === '/api/update') {
@@ -417,7 +470,7 @@ async function main() {
417
470
 
418
471
  // ── Runtime state ──
419
472
  if (req.method === 'GET' && url.pathname === '/api/state') {
420
- jsonResponse(res, { ...bridge.getState(), cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
473
+ jsonResponse(res, { ...bridge.getState(), cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
421
474
  return;
422
475
  }
423
476
  if (req.method === 'GET' && url.pathname === '/api/completions') {
@@ -523,25 +576,25 @@ async function main() {
523
576
  return;
524
577
  }
525
578
 
526
- if (req.method === 'POST' && url.pathname === '/api/codewiki/ask') {
527
- const { question, reportFile } = await readBody(req);
528
- if (!question || typeof question !== 'string') {
529
- jsonResponse(res, { error: true, message: 'Missing "question" field' }, 400);
530
- return;
531
- }
532
- const currentConfig = await loadConfig();
533
- const configStatus = getConfigStatus(currentConfig);
534
- if (configStatus.setupRequired) {
535
- jsonResponse(res, {
536
- error: true,
537
- code: 'CONFIG_REQUIRED',
538
- message: 'Gateway is not configured. Open Settings and set the API Base URL and API Key.',
539
- configStatus
540
- }, 409);
541
- return;
542
- }
543
- const selectedReport = isCodeWikiReportFile(reportFile) ? reportFile : '';
544
- if (bridge.isBusy()) {
579
+ if (req.method === 'POST' && url.pathname === '/api/codewiki/ask') {
580
+ const { question, reportFile } = await readBody(req);
581
+ if (!question || typeof question !== 'string') {
582
+ jsonResponse(res, { error: true, message: 'Missing "question" field' }, 400);
583
+ return;
584
+ }
585
+ const currentConfig = await loadConfig();
586
+ const configStatus = getConfigStatus(currentConfig);
587
+ if (configStatus.setupRequired) {
588
+ jsonResponse(res, {
589
+ error: true,
590
+ code: 'CONFIG_REQUIRED',
591
+ message: 'Gateway is not configured. Open Settings and set the API Base URL and API Key.',
592
+ configStatus
593
+ }, 409);
594
+ return;
595
+ }
596
+ const selectedReport = isCodeWikiReportFile(reportFile) ? reportFile : '';
597
+ if (bridge.isBusy()) {
545
598
  jsonResponse(res, { error: true, message: 'Runtime is busy' }, 409);
546
599
  return;
547
600
  }
@@ -573,7 +626,7 @@ async function main() {
573
626
  // ── Session management ──
574
627
  if (req.method === 'GET' && url.pathname === '/api/sessions') {
575
628
  const sessions = await listSessions(1000);
576
- const enriched = sessions.map(s => ({ ...s, isGeneral: isGeneralProjectDir(s.projectDir) }));
629
+ const enriched = sessions.map(s => ({ ...s, isGeneral: isGeneralProjectDir(s.projectDir) }));
577
630
  jsonResponse(res, enriched);
578
631
  return;
579
632
  }
@@ -586,7 +639,7 @@ async function main() {
586
639
  reused: true,
587
640
  sessionId: bridge.getSessionId(),
588
641
  cwd: currentProjectDir,
589
- isGeneral: isGeneralProjectDir(currentProjectDir)
642
+ isGeneral: isGeneralProjectDir(currentProjectDir)
590
643
  });
591
644
  return;
592
645
  }
@@ -596,7 +649,7 @@ async function main() {
596
649
  });
597
650
  await bridge.switchRuntime(newRuntime);
598
651
  currentProjectDir = process.cwd();
599
- jsonResponse(res, { ok: true, sessionId: session.id, cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
652
+ jsonResponse(res, { ok: true, sessionId: session.id, cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
600
653
  } catch (err) {
601
654
  jsonResponse(res, { error: true, message: err.message }, 500);
602
655
  }
@@ -612,7 +665,7 @@ async function main() {
612
665
  });
613
666
  await bridge.switchRuntime(newRuntime);
614
667
  currentProjectDir = process.cwd();
615
- jsonResponse(res, { ok: true, sessionId, cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
668
+ jsonResponse(res, { ok: true, sessionId, cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
616
669
  } catch (err) {
617
670
  jsonResponse(res, { error: true, message: err.message }, 500);
618
671
  }
@@ -642,7 +695,7 @@ async function main() {
642
695
  nextSessionId = built.session.id;
643
696
  cwd = currentProjectDir;
644
697
  }
645
- jsonResponse(res, { ok: true, removed: result.removed, sessionId: nextSessionId, cwd, isGeneral: isGeneralProjectDir(currentProjectDir) });
698
+ jsonResponse(res, { ok: true, removed: result.removed, sessionId: nextSessionId, cwd, isGeneral: isGeneralProjectDir(currentProjectDir) });
646
699
  } catch (err) {
647
700
  jsonResponse(res, { error: true, message: err.message }, 500);
648
701
  }
@@ -651,7 +704,7 @@ async function main() {
651
704
 
652
705
  // ── Project management ──
653
706
  if (req.method === 'GET' && url.pathname === '/api/project') {
654
- jsonResponse(res, { cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
707
+ jsonResponse(res, { cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
655
708
  return;
656
709
  }
657
710
  if (req.method === 'GET' && url.pathname === '/api/git') {
@@ -726,53 +779,53 @@ async function main() {
726
779
  jsonResponse(res, result);
727
780
  return;
728
781
  }
729
- if (req.method === 'POST' && url.pathname === '/api/project/open') {
730
- const { path: projectPath } = await readBody(req);
731
- if (!projectPath) { jsonResponse(res, { error: true, message: 'Missing path' }, 400); return; }
732
- try {
733
- // Client marker for general workspace
734
- const openingGeneral = projectPath === '__codemini_general__';
735
- const resolved = openingGeneral ? GENERAL_PROJECT_DIR : path.resolve(projectPath);
736
- const stat = await fs.stat(resolved);
737
- if (!stat.isDirectory()) throw new Error('Not a directory');
738
- let built;
739
- if (openingGeneral) {
740
- const all = await listSessions(1000, { includeEmpty: true });
741
- const reusable = all.find((session) =>
742
- isGeneralProjectDir(session.projectDir) &&
743
- Number(session.messageCount || 0) === 0
744
- );
745
- built = reusable
746
- ? await buildRuntimeForSession({ sessionId: reusable.id, model: bridge.getState().model })
747
- : await buildRuntimeForSession({ model: bridge.getState().model, projectDir: GENERAL_PROJECT_DIR });
748
- } else {
749
- process.chdir(resolved);
750
- currentProjectDir = process.cwd();
751
- built = await buildRuntimeForSession({
752
- model: bridge.getState().model
753
- });
754
- }
755
- const { runtime: newRuntime, session } = built;
756
- await bridge.switchRuntime(newRuntime);
757
- currentProjectDir = process.cwd();
758
- jsonResponse(res, { ok: true, cwd: currentProjectDir, sessionId: session.id, isGeneral: isGeneralProjectDir(currentProjectDir) });
759
- } catch (err) {
760
- jsonResponse(res, { error: true, message: err.message }, 400);
761
- }
782
+ if (req.method === 'POST' && url.pathname === '/api/project/open') {
783
+ const { path: projectPath } = await readBody(req);
784
+ if (!projectPath) { jsonResponse(res, { error: true, message: 'Missing path' }, 400); return; }
785
+ try {
786
+ // Client marker for general workspace
787
+ const openingGeneral = projectPath === '__codemini_general__';
788
+ const resolved = openingGeneral ? GENERAL_PROJECT_DIR : path.resolve(projectPath);
789
+ const stat = await fs.stat(resolved);
790
+ if (!stat.isDirectory()) throw new Error('Not a directory');
791
+ let built;
792
+ if (openingGeneral) {
793
+ const all = await listSessions(1000, { includeEmpty: true });
794
+ const reusable = all.find((session) =>
795
+ isGeneralProjectDir(session.projectDir) &&
796
+ Number(session.messageCount || 0) === 0
797
+ );
798
+ built = reusable
799
+ ? await buildRuntimeForSession({ sessionId: reusable.id, model: bridge.getState().model })
800
+ : await buildRuntimeForSession({ model: bridge.getState().model, projectDir: GENERAL_PROJECT_DIR });
801
+ } else {
802
+ process.chdir(resolved);
803
+ currentProjectDir = process.cwd();
804
+ built = await buildRuntimeForSession({
805
+ model: bridge.getState().model
806
+ });
807
+ }
808
+ const { runtime: newRuntime, session } = built;
809
+ await bridge.switchRuntime(newRuntime);
810
+ currentProjectDir = process.cwd();
811
+ jsonResponse(res, { ok: true, cwd: currentProjectDir, sessionId: session.id, isGeneral: isGeneralProjectDir(currentProjectDir) });
812
+ } catch (err) {
813
+ jsonResponse(res, { error: true, message: err.message }, 400);
814
+ }
762
815
  return;
763
- }
764
- if (req.method === 'POST' && url.pathname === '/api/project/browse') {
765
- const { dir } = await readBody(req);
816
+ }
817
+ if (req.method === 'POST' && url.pathname === '/api/project/browse') {
818
+ const { dir } = await readBody(req);
766
819
  const roots = await listProjectRoots();
767
820
  if (!dir && roots.length) {
768
- jsonResponse(res, { path: '', roots, dirs: [] });
769
- return;
770
- }
771
- const base = dir ? path.resolve(dir) : path.resolve('/');
772
- try {
773
- const entries = await fs.readdir(base, { withFileTypes: true });
774
- const dirs = entries
775
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
821
+ jsonResponse(res, { path: '', roots, dirs: [] });
822
+ return;
823
+ }
824
+ const base = dir ? path.resolve(dir) : path.resolve('/');
825
+ try {
826
+ const entries = await fs.readdir(base, { withFileTypes: true });
827
+ const dirs = entries
828
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
776
829
  .sort((a, b) => a.name.localeCompare(b.name))
777
830
  .map(e => ({
778
831
  name: e.name,
@@ -781,40 +834,40 @@ async function main() {
781
834
  }));
782
835
  // Check for .git directories asynchronously
783
836
  await Promise.all(dirs.map(async (d) => {
784
- try { await fs.access(path.join(d.path, '.git')); d.isGit = true; } catch {}
785
- }));
786
- jsonResponse(res, { path: base, roots, dirs });
787
- } catch (err) {
788
- jsonResponse(res, { path: base, roots, dirs: [], error: err.message });
789
- }
790
- return;
791
- }
837
+ try { await fs.access(path.join(d.path, '.git')); d.isGit = true; } catch {}
838
+ }));
839
+ jsonResponse(res, { path: base, roots, dirs });
840
+ } catch (err) {
841
+ jsonResponse(res, { path: base, roots, dirs: [], error: err.message });
842
+ }
843
+ return;
844
+ }
792
845
 
793
- // ── Config management ──
794
- if (req.method === 'GET' && url.pathname === '/api/config/status') {
795
- const config = await loadConfig();
796
- jsonResponse(res, getConfigStatus(config));
797
- return;
798
- }
799
- if (req.method === 'GET' && url.pathname === '/api/config') {
800
- const config = await loadConfig();
801
- jsonResponse(res, config);
802
- return;
803
- }
846
+ // ── Config management ──
847
+ if (req.method === 'GET' && url.pathname === '/api/config/status') {
848
+ const config = await loadConfig();
849
+ jsonResponse(res, getConfigStatus(config));
850
+ return;
851
+ }
852
+ if (req.method === 'GET' && url.pathname === '/api/config') {
853
+ const config = await loadConfig();
854
+ jsonResponse(res, config);
855
+ return;
856
+ }
804
857
  if (req.method === 'POST' && url.pathname === '/api/config/set') {
805
858
  const { key, value } = await readBody(req);
806
859
  if (!key) { jsonResponse(res, { error: true, message: 'Missing key' }, 400); return; }
807
- try {
808
- await setConfigValue(key, value);
809
- const config = await loadConfig();
860
+ try {
861
+ await setConfigValue(key, value);
862
+ const config = await loadConfig();
810
863
  await bridge.reloadConfig(
811
864
  key === 'model.name' ? { model: config.model?.name } : {}
812
865
  );
813
866
  bridge.broadcastRuntimeState();
814
- jsonResponse(res, { ok: true, config });
815
- } catch (err) {
816
- jsonResponse(res, { error: true, message: err.message }, 500);
817
- }
867
+ jsonResponse(res, { ok: true, config });
868
+ } catch (err) {
869
+ jsonResponse(res, { error: true, message: err.message }, 500);
870
+ }
818
871
  return;
819
872
  }
820
873
  if (req.method === 'GET' && url.pathname.startsWith('/api/config/get/')) {
@@ -825,53 +878,54 @@ async function main() {
825
878
  }
826
879
 
827
880
  // ── Skills management ──
828
- if (req.method === 'GET' && url.pathname === '/api/skills') {
829
- try {
830
- const skills = await listSkillEntries({ scope: 'all' });
831
- jsonResponse(res, skills);
832
- } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
833
- return;
881
+ if (req.method === 'GET' && url.pathname === '/api/skills') {
882
+ try {
883
+ const skills = await listSkillEntries({ scope: 'all', cwd: currentProjectDir });
884
+ jsonResponse(res, skills);
885
+ } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
886
+ return;
834
887
  }
835
888
  if (req.method === 'GET' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/content')) {
836
- const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/content'.length));
837
- try {
838
- const entries = await listSkillEntries({ scope: 'all' });
839
- const skill = entries.find(s => s.name === name);
840
- if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
841
- const content = await fs.readFile(skill.path, 'utf8');
889
+ const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/content'.length));
890
+ try {
891
+ const entries = await listSkillEntries({ scope: 'all', cwd: currentProjectDir });
892
+ const skill = entries.find(s => s.name === name);
893
+ if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
894
+ const content = await fs.readFile(skill.path, 'utf8');
842
895
  jsonResponse(res, { name: skill.name, content, scope: skill.scope });
843
896
  } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
844
897
  return;
845
898
  }
846
- if (req.method === 'POST' && url.pathname === '/api/skills/create') {
847
- const { name, description, content } = await readBody(req);
848
- if (!name || !content) { jsonResponse(res, { error: true, message: 'Missing name or content' }, 400); return; }
849
- try {
850
- const skillDir = path.join(getSkillsDir(), name);
851
- await fs.mkdir(skillDir, { recursive: true });
852
- const skillFile = path.join(skillDir, 'SKILL.md');
853
- await fs.writeFile(skillFile, content, 'utf8');
854
- const { createHash } = await import('node:crypto');
855
- const hash = createHash('sha256').update(content).digest('hex');
856
- await upsertSkillRegistryEntry(undefined, {
857
- name, version: '0.1.0', description: description || '', enabled: true,
858
- source: 'web-ui', entryFile: 'SKILL.md', sha256: hash, installedAt: new Date().toISOString()
859
- });
860
- const config = await loadConfig();
861
- config.skills = config.skills || {};
862
- config.skills.enabled = config.skills.enabled || {};
899
+ if (req.method === 'POST' && url.pathname === '/api/skills/create') {
900
+ const { name, description, content } = await readBody(req);
901
+ if (!name || !content) { jsonResponse(res, { error: true, message: 'Missing name or content' }, 400); return; }
902
+ try {
903
+ const skillDir = path.join(getProjectSkillsDir(currentProjectDir), name);
904
+ await fs.mkdir(skillDir, { recursive: true });
905
+ const skillFile = path.join(skillDir, 'SKILL.md');
906
+ await fs.writeFile(skillFile, content, 'utf8');
907
+ await upsertProjectSkillMetadata(currentProjectDir, name, {
908
+ description: description || '',
909
+ mode: 'agent_requested',
910
+ triggers: [],
911
+ enabled: true,
912
+ priority: 50
913
+ });
914
+ const config = await loadConfig();
915
+ config.skills = config.skills || {};
916
+ config.skills.enabled = config.skills.enabled || {};
863
917
  config.skills.enabled[name] = true;
864
918
  await saveConfig(config);
865
919
  jsonResponse(res, { ok: true, name });
866
920
  } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
867
921
  return;
868
922
  }
869
- if (req.method === 'PUT' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/content')) {
870
- const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/content'.length));
871
- const { content } = await readBody(req);
872
- if (!content) { jsonResponse(res, { error: true, message: 'Missing content' }, 400); return; }
873
- try {
874
- const entries = await listSkillEntries({ scope: 'all' });
923
+ if (req.method === 'PUT' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/content')) {
924
+ const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/content'.length));
925
+ const { content } = await readBody(req);
926
+ if (!content) { jsonResponse(res, { error: true, message: 'Missing content' }, 400); return; }
927
+ try {
928
+ const entries = await listSkillEntries({ scope: 'all', cwd: currentProjectDir });
875
929
  const skill = entries.find(s => s.name === name);
876
930
  if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
877
931
  if (skill.scope === 'builtin') { jsonResponse(res, { error: true, message: 'Cannot edit builtin skill' }, 403); return; }
@@ -889,25 +943,56 @@ async function main() {
889
943
  if (skill.scope === 'builtin') { jsonResponse(res, { error: true, message: 'Cannot delete builtin skill' }, 403); return; }
890
944
  const dir = path.dirname(skill.path);
891
945
  await fs.rm(dir, { recursive: true, force: true });
892
- const registry = await readSkillRegistry();
893
- registry.skills = (registry.skills || []).filter(s => s.name !== name);
894
- await writeSkillRegistry(undefined, registry);
895
- const config = await loadConfig();
896
- if (config.skills?.enabled) delete config.skills.enabled[name];
897
- await saveConfig(config);
946
+ const registry = await readSkillRegistry();
947
+ registry.skills = (registry.skills || []).filter(s => s.name !== name);
948
+ await writeSkillRegistry(undefined, registry);
949
+ const catalog = await readProjectSkillCatalog(currentProjectDir);
950
+ if (catalog.skills?.[name]) {
951
+ delete catalog.skills[name];
952
+ await writeProjectSkillCatalog(currentProjectDir, catalog);
953
+ }
954
+ const config = await loadConfig();
955
+ if (config.skills?.enabled) delete config.skills.enabled[name];
956
+ await saveConfig(config);
898
957
  jsonResponse(res, { ok: true });
899
958
  } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
900
- return;
901
- }
902
- if (req.method === 'POST' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/toggle')) {
903
- const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/toggle'.length));
904
- const { enabled } = await readBody(req);
905
- try {
906
- const entries = await listSkillEntries({ scope: 'all' });
907
- const skill = entries.find(s => s.name === name);
908
- if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
909
- if (skill.scope === 'builtin') { jsonResponse(res, { error: true, message: 'Cannot toggle builtin skill' }, 403); return; }
910
- const config = await loadConfig();
959
+ return;
960
+ }
961
+ if (req.method === 'PUT' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/metadata')) {
962
+ const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/metadata'.length));
963
+ const body = await readBody(req);
964
+ try {
965
+ const entries = await listSkillEntries({ scope: 'all', cwd: currentProjectDir });
966
+ const skill = entries.find(s => s.name === name);
967
+ if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
968
+ const metadata = await upsertProjectSkillMetadata(currentProjectDir, name, body || {});
969
+ if (skill.scope !== 'builtin' && body?.enabled !== undefined) {
970
+ const config = await loadConfig();
971
+ config.skills = config.skills || {};
972
+ config.skills.enabled = config.skills.enabled || {};
973
+ config.skills.enabled[name] = body.enabled !== false;
974
+ await saveConfig(config);
975
+ const registry = await readSkillRegistry();
976
+ const idx = registry.skills.findIndex(s => s.name === name);
977
+ if (idx !== -1) { registry.skills[idx].enabled = body.enabled !== false; await writeSkillRegistry(undefined, registry); }
978
+ }
979
+ jsonResponse(res, { ok: true, name, metadata });
980
+ } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
981
+ return;
982
+ }
983
+ if (req.method === 'POST' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/toggle')) {
984
+ const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/toggle'.length));
985
+ const { enabled } = await readBody(req);
986
+ try {
987
+ const entries = await listSkillEntries({ scope: 'all', cwd: currentProjectDir });
988
+ const skill = entries.find(s => s.name === name);
989
+ if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
990
+ if (skill.scope === 'builtin') {
991
+ const metadata = await upsertProjectSkillMetadata(currentProjectDir, name, { enabled });
992
+ jsonResponse(res, { ok: true, name, metadata });
993
+ return;
994
+ }
995
+ const config = await loadConfig();
911
996
  config.skills = config.skills || {};
912
997
  config.skills.enabled = config.skills.enabled || {};
913
998
  config.skills.enabled[name] = !!enabled;