codemini-cli 0.5.7 → 0.5.9

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,23 +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
- function isGeneralProjectDir(value) {
24
- if (!value) return false;
25
- return path.resolve(value) === path.resolve(GENERAL_PROJECT_DIR);
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);
39
+ }
40
+ return out;
41
+ }
42
+
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: {} };
50
+ }
26
51
  }
27
52
 
28
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
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
+ }
62
+
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));
29
113
  const CLIENT_SOURCE_DIR = path.join(__dirname, 'client');
30
114
  let CLIENT_DIR = CLIENT_SOURCE_DIR;
31
115
  try {
@@ -34,33 +118,33 @@ try {
34
118
  if (stat.isDirectory()) CLIENT_DIR = distDir;
35
119
  } catch {}
36
120
 
37
- const MIME_TYPES = {
121
+ const MIME_TYPES = {
38
122
  '.html': 'text/html; charset=utf-8',
39
123
  '.css': 'text/css; charset=utf-8',
40
124
  '.js': 'text/javascript; charset=utf-8',
41
125
  '.json': 'application/json',
42
126
  '.svg': 'image/svg+xml',
43
127
  '.png': 'image/png',
44
- '.ico': 'image/x-icon'
45
- };
46
-
47
- const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:8000/v1';
48
-
49
- function normalizeBaseUrl(value) {
50
- return String(value || '').trim().replace(/\/+$/, '');
51
- }
52
-
53
- function getConfigStatus(config) {
54
- const baseUrl = normalizeBaseUrl(config?.gateway?.base_url);
55
- const apiKey = String(config?.gateway?.api_key || '').trim();
56
- const setupRequired = !baseUrl || (baseUrl === DEFAULT_GATEWAY_BASE_URL && !apiKey);
57
- return {
58
- setupRequired,
59
- baseUrl,
60
- hasApiKey: !!apiKey,
61
- reason: setupRequired ? 'gateway_not_configured' : ''
62
- };
63
- }
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
+ }
64
148
 
65
149
  function parseArgs(argv) {
66
150
  const parsed = { port: 3210, session: undefined, model: undefined, project: undefined, open: true };
@@ -170,10 +254,14 @@ function collectSessionPathHints(session) {
170
254
  return hints;
171
255
  }
172
256
 
173
- async function existingDirectoryForHint(rawHint) {
174
- let candidate = normalizeProjectPath(rawHint);
175
- if (!candidate) return '';
176
- 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, '');
177
265
  for (let i = 0; i < 8 && candidate && candidate !== path.dirname(candidate); i += 1) {
178
266
  try {
179
267
  const stat = await fs.stat(candidate);
@@ -260,7 +348,7 @@ async function buildRuntimeForSession({ sessionId, model, projectDir }) {
260
348
  model: model || config.model?.name,
261
349
  systemPrompt
262
350
  });
263
- return { runtime, config, session, cwd: process.cwd(), isGeneral: isGeneralProjectDir(process.cwd()) };
351
+ return { runtime, config, session, cwd: process.cwd(), isGeneral: isGeneralProjectDir(process.cwd()) };
264
352
  }
265
353
 
266
354
  async function main() {
@@ -317,25 +405,25 @@ async function main() {
317
405
  return;
318
406
  }
319
407
 
320
- // ── Submit / Abort / Approval ──
321
- if (req.method === 'POST' && url.pathname === '/api/submit') {
322
- const { line, readOnlyCodeWiki } = await readBody(req);
323
- if (!line || typeof line !== 'string') { jsonResponse(res, { error: true, message: 'Missing "line" field' }, 400); return; }
324
- const currentConfig = await loadConfig();
325
- const configStatus = getConfigStatus(currentConfig);
326
- if (configStatus.setupRequired) {
327
- jsonResponse(res, {
328
- error: true,
329
- code: 'CONFIG_REQUIRED',
330
- message: 'Gateway is not configured. Open Settings and set the API Base URL and API Key.',
331
- configStatus
332
- }, 409);
333
- return;
334
- }
335
- const result = bridge.handleSubmit(line, { readOnlyCodeWiki: readOnlyCodeWiki === true });
336
- jsonResponse(res, result);
337
- return;
338
- }
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
+ }
339
427
  if (req.method === 'POST' && url.pathname === '/api/abort') {
340
428
  bridge.handleAbort();
341
429
  jsonResponse(res, { ok: true });
@@ -367,7 +455,7 @@ async function main() {
367
455
  try {
368
456
  latest = execSync('npm view codemini-cli version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
369
457
  } catch {}
370
- jsonResponse(res, { current: VERSION, latest });
458
+ jsonResponse(res, { current: VERSION, latest });
371
459
  return;
372
460
  }
373
461
  if (req.method === 'POST' && url.pathname === '/api/update') {
@@ -382,7 +470,7 @@ async function main() {
382
470
 
383
471
  // ── Runtime state ──
384
472
  if (req.method === 'GET' && url.pathname === '/api/state') {
385
- jsonResponse(res, { ...bridge.getState(), cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
473
+ jsonResponse(res, { ...bridge.getState(), cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
386
474
  return;
387
475
  }
388
476
  if (req.method === 'GET' && url.pathname === '/api/completions') {
@@ -488,25 +576,25 @@ async function main() {
488
576
  return;
489
577
  }
490
578
 
491
- if (req.method === 'POST' && url.pathname === '/api/codewiki/ask') {
492
- const { question, reportFile } = await readBody(req);
493
- if (!question || typeof question !== 'string') {
494
- jsonResponse(res, { error: true, message: 'Missing "question" field' }, 400);
495
- return;
496
- }
497
- const currentConfig = await loadConfig();
498
- const configStatus = getConfigStatus(currentConfig);
499
- if (configStatus.setupRequired) {
500
- jsonResponse(res, {
501
- error: true,
502
- code: 'CONFIG_REQUIRED',
503
- message: 'Gateway is not configured. Open Settings and set the API Base URL and API Key.',
504
- configStatus
505
- }, 409);
506
- return;
507
- }
508
- const selectedReport = isCodeWikiReportFile(reportFile) ? reportFile : '';
509
- 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()) {
510
598
  jsonResponse(res, { error: true, message: 'Runtime is busy' }, 409);
511
599
  return;
512
600
  }
@@ -538,7 +626,7 @@ async function main() {
538
626
  // ── Session management ──
539
627
  if (req.method === 'GET' && url.pathname === '/api/sessions') {
540
628
  const sessions = await listSessions(1000);
541
- const enriched = sessions.map(s => ({ ...s, isGeneral: isGeneralProjectDir(s.projectDir) }));
629
+ const enriched = sessions.map(s => ({ ...s, isGeneral: isGeneralProjectDir(s.projectDir) }));
542
630
  jsonResponse(res, enriched);
543
631
  return;
544
632
  }
@@ -551,7 +639,7 @@ async function main() {
551
639
  reused: true,
552
640
  sessionId: bridge.getSessionId(),
553
641
  cwd: currentProjectDir,
554
- isGeneral: isGeneralProjectDir(currentProjectDir)
642
+ isGeneral: isGeneralProjectDir(currentProjectDir)
555
643
  });
556
644
  return;
557
645
  }
@@ -561,7 +649,7 @@ async function main() {
561
649
  });
562
650
  await bridge.switchRuntime(newRuntime);
563
651
  currentProjectDir = process.cwd();
564
- 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) });
565
653
  } catch (err) {
566
654
  jsonResponse(res, { error: true, message: err.message }, 500);
567
655
  }
@@ -577,7 +665,7 @@ async function main() {
577
665
  });
578
666
  await bridge.switchRuntime(newRuntime);
579
667
  currentProjectDir = process.cwd();
580
- jsonResponse(res, { ok: true, sessionId, cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
668
+ jsonResponse(res, { ok: true, sessionId, cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
581
669
  } catch (err) {
582
670
  jsonResponse(res, { error: true, message: err.message }, 500);
583
671
  }
@@ -607,7 +695,7 @@ async function main() {
607
695
  nextSessionId = built.session.id;
608
696
  cwd = currentProjectDir;
609
697
  }
610
- 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) });
611
699
  } catch (err) {
612
700
  jsonResponse(res, { error: true, message: err.message }, 500);
613
701
  }
@@ -616,7 +704,7 @@ async function main() {
616
704
 
617
705
  // ── Project management ──
618
706
  if (req.method === 'GET' && url.pathname === '/api/project') {
619
- jsonResponse(res, { cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
707
+ jsonResponse(res, { cwd: currentProjectDir, isGeneral: isGeneralProjectDir(currentProjectDir) });
620
708
  return;
621
709
  }
622
710
  if (req.method === 'GET' && url.pathname === '/api/git') {
@@ -691,43 +779,48 @@ async function main() {
691
779
  jsonResponse(res, result);
692
780
  return;
693
781
  }
694
- if (req.method === 'POST' && url.pathname === '/api/project/open') {
695
- const { path: projectPath } = await readBody(req);
696
- if (!projectPath) { jsonResponse(res, { error: true, message: 'Missing path' }, 400); return; }
697
- try {
698
- // Client marker for general workspace
699
- const openingGeneral = projectPath === '__codemini_general__';
700
- const resolved = openingGeneral ? GENERAL_PROJECT_DIR : path.resolve(projectPath);
701
- const stat = await fs.stat(resolved);
702
- if (!stat.isDirectory()) throw new Error('Not a directory');
703
- let built;
704
- if (openingGeneral) {
705
- const all = await listSessions(1000, { includeEmpty: true });
706
- const reusable = all.find((session) =>
707
- isGeneralProjectDir(session.projectDir) &&
708
- Number(session.messageCount || 0) === 0
709
- );
710
- built = reusable
711
- ? await buildRuntimeForSession({ sessionId: reusable.id, model: bridge.getState().model })
712
- : await buildRuntimeForSession({ model: bridge.getState().model, projectDir: GENERAL_PROJECT_DIR });
713
- } else {
714
- process.chdir(resolved);
715
- currentProjectDir = process.cwd();
716
- built = await buildRuntimeForSession({
717
- model: bridge.getState().model
718
- });
719
- }
720
- const { runtime: newRuntime, session } = built;
721
- await bridge.switchRuntime(newRuntime);
722
- currentProjectDir = process.cwd();
723
- jsonResponse(res, { ok: true, cwd: currentProjectDir, sessionId: session.id, isGeneral: isGeneralProjectDir(currentProjectDir) });
724
- } catch (err) {
725
- jsonResponse(res, { error: true, message: err.message }, 400);
726
- }
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
+ }
727
815
  return;
728
816
  }
729
817
  if (req.method === 'POST' && url.pathname === '/api/project/browse') {
730
818
  const { dir } = await readBody(req);
819
+ const roots = await listProjectRoots();
820
+ if (!dir && roots.length) {
821
+ jsonResponse(res, { path: '', roots, dirs: [] });
822
+ return;
823
+ }
731
824
  const base = dir ? path.resolve(dir) : path.resolve('/');
732
825
  try {
733
826
  const entries = await fs.readdir(base, { withFileTypes: true });
@@ -743,36 +836,38 @@ async function main() {
743
836
  await Promise.all(dirs.map(async (d) => {
744
837
  try { await fs.access(path.join(d.path, '.git')); d.isGit = true; } catch {}
745
838
  }));
746
- jsonResponse(res, { path: base, dirs });
839
+ jsonResponse(res, { path: base, roots, dirs });
747
840
  } catch (err) {
748
- jsonResponse(res, { path: base, dirs: [], error: err.message });
841
+ jsonResponse(res, { path: base, roots, dirs: [], error: err.message });
749
842
  }
750
843
  return;
751
844
  }
752
845
 
753
- // ── Config management ──
754
- if (req.method === 'GET' && url.pathname === '/api/config/status') {
755
- const config = await loadConfig();
756
- jsonResponse(res, getConfigStatus(config));
757
- return;
758
- }
759
- if (req.method === 'GET' && url.pathname === '/api/config') {
760
- const config = await loadConfig();
761
- jsonResponse(res, config);
762
- return;
763
- }
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
+ }
764
857
  if (req.method === 'POST' && url.pathname === '/api/config/set') {
765
858
  const { key, value } = await readBody(req);
766
859
  if (!key) { jsonResponse(res, { error: true, message: 'Missing key' }, 400); return; }
767
- try {
768
- await setConfigValue(key, value);
769
- const config = await loadConfig();
770
- await bridge.reloadConfig();
860
+ try {
861
+ await setConfigValue(key, value);
862
+ const config = await loadConfig();
863
+ await bridge.reloadConfig(
864
+ key === 'model.name' ? { model: config.model?.name } : {}
865
+ );
771
866
  bridge.broadcastRuntimeState();
772
- jsonResponse(res, { ok: true, config });
773
- } catch (err) {
774
- jsonResponse(res, { error: true, message: err.message }, 500);
775
- }
867
+ jsonResponse(res, { ok: true, config });
868
+ } catch (err) {
869
+ jsonResponse(res, { error: true, message: err.message }, 500);
870
+ }
776
871
  return;
777
872
  }
778
873
  if (req.method === 'GET' && url.pathname.startsWith('/api/config/get/')) {
@@ -783,53 +878,54 @@ async function main() {
783
878
  }
784
879
 
785
880
  // ── Skills management ──
786
- if (req.method === 'GET' && url.pathname === '/api/skills') {
787
- try {
788
- const skills = await listSkillEntries({ scope: 'all' });
789
- jsonResponse(res, skills);
790
- } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
791
- 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;
792
887
  }
793
888
  if (req.method === 'GET' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/content')) {
794
- const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/content'.length));
795
- try {
796
- const entries = await listSkillEntries({ scope: 'all' });
797
- const skill = entries.find(s => s.name === name);
798
- if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
799
- 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');
800
895
  jsonResponse(res, { name: skill.name, content, scope: skill.scope });
801
896
  } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
802
897
  return;
803
898
  }
804
- if (req.method === 'POST' && url.pathname === '/api/skills/create') {
805
- const { name, description, content } = await readBody(req);
806
- if (!name || !content) { jsonResponse(res, { error: true, message: 'Missing name or content' }, 400); return; }
807
- try {
808
- const skillDir = path.join(getSkillsDir(), name);
809
- await fs.mkdir(skillDir, { recursive: true });
810
- const skillFile = path.join(skillDir, 'SKILL.md');
811
- await fs.writeFile(skillFile, content, 'utf8');
812
- const { createHash } = await import('node:crypto');
813
- const hash = createHash('sha256').update(content).digest('hex');
814
- await upsertSkillRegistryEntry(undefined, {
815
- name, version: '0.1.0', description: description || '', enabled: true,
816
- source: 'web-ui', entryFile: 'SKILL.md', sha256: hash, installedAt: new Date().toISOString()
817
- });
818
- const config = await loadConfig();
819
- config.skills = config.skills || {};
820
- 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 || {};
821
917
  config.skills.enabled[name] = true;
822
918
  await saveConfig(config);
823
919
  jsonResponse(res, { ok: true, name });
824
920
  } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
825
921
  return;
826
922
  }
827
- if (req.method === 'PUT' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/content')) {
828
- const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/content'.length));
829
- const { content } = await readBody(req);
830
- if (!content) { jsonResponse(res, { error: true, message: 'Missing content' }, 400); return; }
831
- try {
832
- 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 });
833
929
  const skill = entries.find(s => s.name === name);
834
930
  if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
835
931
  if (skill.scope === 'builtin') { jsonResponse(res, { error: true, message: 'Cannot edit builtin skill' }, 403); return; }
@@ -847,25 +943,56 @@ async function main() {
847
943
  if (skill.scope === 'builtin') { jsonResponse(res, { error: true, message: 'Cannot delete builtin skill' }, 403); return; }
848
944
  const dir = path.dirname(skill.path);
849
945
  await fs.rm(dir, { recursive: true, force: true });
850
- const registry = await readSkillRegistry();
851
- registry.skills = (registry.skills || []).filter(s => s.name !== name);
852
- await writeSkillRegistry(undefined, registry);
853
- const config = await loadConfig();
854
- if (config.skills?.enabled) delete config.skills.enabled[name];
855
- 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);
856
957
  jsonResponse(res, { ok: true });
857
958
  } catch (err) { jsonResponse(res, { error: true, message: err.message }, 500); }
858
- return;
859
- }
860
- if (req.method === 'POST' && url.pathname.startsWith('/api/skills/') && url.pathname.endsWith('/toggle')) {
861
- const name = decodeURIComponent(url.pathname.slice('/api/skills/'.length, -'/toggle'.length));
862
- const { enabled } = await readBody(req);
863
- try {
864
- const entries = await listSkillEntries({ scope: 'all' });
865
- const skill = entries.find(s => s.name === name);
866
- if (!skill) { jsonResponse(res, { error: true, message: 'Skill not found' }, 404); return; }
867
- if (skill.scope === 'builtin') { jsonResponse(res, { error: true, message: 'Cannot toggle builtin skill' }, 403); return; }
868
- 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();
869
996
  config.skills = config.skills || {};
870
997
  config.skills.enabled = config.skills.enabled || {};
871
998
  config.skills.enabled[name] = !!enabled;