@axplusb/kepler 1.0.5 → 1.0.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.
@@ -5,15 +5,16 @@
5
5
  * This bridge translates those into OCC tool calls and wraps the results.
6
6
  *
7
7
  * Safety guardrails integrated — prevents destructive operations on source code.
8
- * 20 tools mapped across file, search, shell, validation, and Git operations.
8
+ * Tools are mapped across file, search, shell, validation, and Git operations.
9
9
  */
10
10
 
11
11
  import { createToolRegistry } from '../tools/registry.mjs';
12
12
  import { filterOutput } from './output-filter.mjs';
13
13
  import { validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
14
14
  import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
15
- import { ContextRetriever } from '../context/retriever.mjs';
16
15
  import { analyzeCode } from '../context/ast-parser.mjs';
16
+ import { ProjectRegistry } from '../tools/project-overview.mjs';
17
+ import { SkillsLoader } from '../skills/loader.mjs';
17
18
  import * as fs from 'node:fs';
18
19
  import * as path from 'node:path';
19
20
  import { execSync } from 'node:child_process';
@@ -21,29 +22,36 @@ import { execSync } from 'node:child_process';
21
22
  /**
22
23
  * Create a tool executor that bridges Tarang tool names to OCC tools.
23
24
  * @param {Object} [options]
24
- * @param {ContextRetriever} [options.retriever] - BM25 retriever for search_code
25
+ * @param {ProjectRegistry} [options.projectRegistry] - session-owned project registry
25
26
  * @returns {{ execute(name, args): Promise<Object>, listTools(): string[] }}
26
27
  */
27
- export function createToolExecutor({ retriever } = {}) {
28
+ export function createToolExecutor({
29
+ projectRegistry = new ProjectRegistry(),
30
+ skillsLoader = new SkillsLoader().load(process.cwd()),
31
+ } = {}) {
28
32
  const occRegistry = createToolRegistry();
33
+ const skillTool = occRegistry.get('Skill');
34
+ if (skillTool) skillTool._skillsLoader = skillsLoader;
29
35
  let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
30
36
 
31
- // Capture CWD at creation time OCC Bash tool mutates process.cwd() after shell commands
32
- const PROJECT_CWD = process.cwd();
37
+ function resolvePath(p, args = {}, options = {}) {
38
+ return projectRegistry.resolvePath(p, args.project_id, options);
39
+ }
33
40
 
34
- /**
35
- * Resolve a path relative to project CWD, with traversal protection.
36
- */
37
- function resolvePath(p) {
38
- if (!p) return PROJECT_CWD;
39
- const resolved = path.resolve(PROJECT_CWD, p);
40
- // Prevent path traversal outside CWD's parent
41
- const cwd = process.cwd();
42
- const cwdParent = path.dirname(cwd);
43
- if (!resolved.startsWith(cwdParent)) {
44
- throw new Error(`Path traversal blocked: ${p}`);
45
- }
46
- return resolved;
41
+ function projectRootFor(filePath) {
42
+ const project = projectRegistry.projectForPath(filePath);
43
+ if (!project) throw new Error(`No registered project contains path: ${filePath}`);
44
+ return project.resource.root;
45
+ }
46
+
47
+ function commandCwd(args = {}) {
48
+ return resolvePath(args.cwd || null, args);
49
+ }
50
+
51
+ function updateProjectIndex(filePath) {
52
+ try {
53
+ projectRegistry.projectForPath(filePath)?.retriever.updateFile(filePath);
54
+ } catch { /* best effort */ }
47
55
  }
48
56
 
49
57
  /**
@@ -115,7 +123,7 @@ export function createToolExecutor({ retriever } = {}) {
115
123
  if (!shellCheck.safe) {
116
124
  return {
117
125
  success: false,
118
- output: `BLOCKED: ${shellCheck.reason}. Your current working directory is ${process.cwd()} — search within it, not from filesystem root.`,
126
+ output: `BLOCKED: ${shellCheck.reason}. Work only inside a registered project root.`,
119
127
  _tool: 'shell', _blocked: true,
120
128
  };
121
129
  }
@@ -136,13 +144,14 @@ export function createToolExecutor({ retriever } = {}) {
136
144
  args._riskReason = classification.reason || shellCheck.reason;
137
145
  }
138
146
  args._classification = classification.classification; // 'safe' or 'contained'
147
+ const cwd = commandCwd(args);
139
148
 
140
149
  // Pre-check: if command is rm/unlink, verify targets exist first
141
150
  const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
142
151
  if (rmMatch) {
143
152
  const targets = rmMatch[1].split(/\s+/).filter(t => !t.startsWith('-'));
144
153
  const missing = targets.filter(t => {
145
- try { return !fs.existsSync(path.resolve(process.cwd(), t)); } catch { return true; }
154
+ try { return !fs.existsSync(path.resolve(cwd, t)); } catch { return true; }
146
155
  });
147
156
  if (missing.length > 0 && missing.length === targets.length) {
148
157
  return {
@@ -159,6 +168,7 @@ export function createToolExecutor({ retriever } = {}) {
159
168
  command: args.command,
160
169
  timeout: args.timeout,
161
170
  description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
171
+ cwd,
162
172
  });
163
173
  const rawOutput = typeof result === 'string' ? result : String(result);
164
174
  const exitMatch = rawOutput.match(/Exit code: (\d+)/);
@@ -182,7 +192,7 @@ export function createToolExecutor({ retriever } = {}) {
182
192
 
183
193
  // 2. read_file → Read (with smart truncation for large files)
184
194
  read_file: async (args) => {
185
- const filePath = resolvePath(args.file_path || args.path);
195
+ const filePath = resolvePath(args.file_path || args.path, args);
186
196
  const hasLineRange = args.start_line || args.end_line || args.offset || args.limit;
187
197
 
188
198
  // Nudge: if reading shallow overview files, remind agent to search deeper
@@ -242,10 +252,10 @@ export function createToolExecutor({ retriever } = {}) {
242
252
  write_file: async (args) => {
243
253
  const rawPath = args.file_path || args.path;
244
254
  if (!rawPath || rawPath === 'file' || rawPath.length < 3) {
245
- return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Use an ABSOLUTE path like "${process.cwd()}/src/main.py"`, _tool: 'write_file' };
255
+ return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Register the project, then use an absolute path.`, _tool: 'write_file' };
246
256
  }
247
- const filePath = resolvePath(rawPath);
248
- const writeCheck = validateWrite(filePath, args.content);
257
+ const filePath = resolvePath(rawPath, args, { allowMissing: true });
258
+ const writeCheck = validateWrite(filePath, args.content, projectRootFor(filePath));
249
259
  if (!writeCheck.safe) {
250
260
  return { success: false, output: `🛡️ BLOCKED: ${writeCheck.reason}`, _tool: 'write_file', _blocked: true };
251
261
  }
@@ -260,6 +270,7 @@ export function createToolExecutor({ retriever } = {}) {
260
270
  content: args.content,
261
271
  });
262
272
  const wrapped = wrapResult(result, 'write_file');
273
+ updateProjectIndex(filePath);
263
274
 
264
275
  // Auto-lint the written file
265
276
  const lintOutput = autoLint(filePath);
@@ -287,10 +298,10 @@ export function createToolExecutor({ retriever } = {}) {
287
298
  errors.push('Missing path in file entry');
288
299
  continue;
289
300
  }
290
- const filePath = resolvePath(rawPath);
301
+ const filePath = resolvePath(rawPath, file, { allowMissing: true });
291
302
  const content = file.content || '';
292
303
 
293
- const writeCheck = validateWrite(filePath, content);
304
+ const writeCheck = validateWrite(filePath, content, projectRootFor(filePath));
294
305
  if (!writeCheck.safe) {
295
306
  errors.push(`${rawPath}: BLOCKED — ${writeCheck.reason}`);
296
307
  continue;
@@ -309,6 +320,7 @@ export function createToolExecutor({ retriever } = {}) {
309
320
  } catch { /* file may not exist yet */ }
310
321
 
311
322
  await occRegistry.call('Write', { file_path: filePath, content });
323
+ updateProjectIndex(filePath);
312
324
  results.push(rawPath);
313
325
  } catch (err) {
314
326
  errors.push(`${rawPath}: ${err.message}`);
@@ -335,7 +347,11 @@ export function createToolExecutor({ retriever } = {}) {
335
347
  // 4. edit_file → Edit + auto-lint + auto-fallback to sed
336
348
  edit_file: async (args) => {
337
349
  const rawPath = args.file_path || args.path;
338
- const filePath = resolvePath(rawPath);
350
+ const filePath = resolvePath(rawPath, args);
351
+ const writeCheck = validateWrite(filePath, args.replace, projectRootFor(filePath));
352
+ if (!writeCheck.safe) {
353
+ return { success: false, output: `BLOCKED: ${writeCheck.reason}`, _tool: 'edit_file', _blocked: true };
354
+ }
339
355
  // OCC Edit requires Read first
340
356
  try {
341
357
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
@@ -366,7 +382,11 @@ content = content.replace(old, new, 1)
366
382
  with open('${filePath}', 'w') as f: f.write(content)
367
383
  print('OK: replaced')
368
384
  "`;
369
- const fallbackResult = execSync(pyCmd, { encoding: 'utf-8', timeout: 5000, cwd: PROJECT_CWD });
385
+ const fallbackResult = execSync(pyCmd, {
386
+ encoding: 'utf-8',
387
+ timeout: 5000,
388
+ cwd: projectRootFor(filePath),
389
+ });
370
390
  result = `Edited ${filePath} (via fallback): ${fallbackResult.trim()}`;
371
391
  } catch (sedErr) {
372
392
  return { success: false, output: `edit_file failed: ${editErr?.message || 'unknown'}. Fallback also failed: ${sedErr?.message || 'unknown'}. Try shell(sed) manually.`, _tool: 'edit_file' };
@@ -374,6 +394,7 @@ print('OK: replaced')
374
394
  }
375
395
 
376
396
  const wrapped = wrapResult(result, 'edit_file');
397
+ updateProjectIndex(filePath);
377
398
 
378
399
  // Auto-lint the edited file
379
400
  const lintOutput = autoLint(filePath);
@@ -389,7 +410,7 @@ print('OK: replaced')
389
410
  list_files: async (args) => {
390
411
  const result = await occRegistry.call('Glob', {
391
412
  pattern: args.pattern || '**/*',
392
- path: args.path ? resolvePath(args.path) : undefined,
413
+ path: resolvePath(args.path || null, args),
393
414
  });
394
415
  const output = typeof result === 'string' ? result : String(result);
395
416
  const files = output.split('\n').filter(Boolean);
@@ -407,7 +428,24 @@ print('OK: replaced')
407
428
  const query = args.query || args.pattern;
408
429
  if (!query) return { success: false, output: 'query required', _tool: 'search_code' };
409
430
 
410
- const searchPath = args.path ? resolvePath(args.path) : PROJECT_CWD;
431
+ let project;
432
+ if (args.project_id) {
433
+ project = projectRegistry.get(args.project_id);
434
+ if (!project) {
435
+ return { success: false, output: `Unknown project_id: ${args.project_id}`, _tool: 'search_code' };
436
+ }
437
+ } else if (args.path) {
438
+ project = projectRegistry.projectForPath(resolvePath(args.path, args));
439
+ } else if (projectRegistry.resources().length === 1) {
440
+ project = projectRegistry.get(projectRegistry.resources()[0].project_id);
441
+ } else {
442
+ return {
443
+ success: false,
444
+ output: 'search_code requires project_id when multiple or no projects are registered',
445
+ _tool: 'search_code',
446
+ };
447
+ }
448
+ const searchPath = args.path ? resolvePath(args.path, args) : project.resource.root;
411
449
  const parts = [];
412
450
 
413
451
  // Layer 1: ripgrep — exact text matches with context
@@ -420,9 +458,9 @@ print('OK: replaced')
420
458
  } catch { /* rg not found or no results */ }
421
459
 
422
460
  // Layer 2: BM25 — semantic relevance (finds related code even without exact match)
423
- if (retriever) {
424
- if (!retriever.index) retriever.loadIndex();
425
- const chunks = retriever.retrieve(query, 5);
461
+ if (project?.retriever) {
462
+ if (!project.retriever.index) project.retriever.loadIndex();
463
+ const chunks = project.retriever.retrieve(query, 5);
426
464
  if (chunks.length > 0) {
427
465
  const bm25Output = chunks.map(c => {
428
466
  const score = c.score?.toFixed(2) || '?';
@@ -461,7 +499,7 @@ print('OK: replaced')
461
499
  if (query.includes('*') || query.includes('?')) {
462
500
  const result = await occRegistry.call('Glob', {
463
501
  pattern: query,
464
- path: args.path ? resolvePath(args.path) : undefined,
502
+ path: resolvePath(args.path || null, args),
465
503
  });
466
504
  const output = typeof result === 'string' ? result : String(result);
467
505
  return {
@@ -475,7 +513,7 @@ print('OK: replaced')
475
513
  // For text patterns: grep with context lines (like grep -n -C 3)
476
514
  const result = await occRegistry.call('Grep', {
477
515
  pattern: query,
478
- path: args.path ? resolvePath(args.path) : undefined,
516
+ path: resolvePath(args.path || null, args),
479
517
  output_mode: 'content',
480
518
  '-n': true,
481
519
  '-C': 3,
@@ -495,12 +533,12 @@ print('OK: replaced')
495
533
  const pattern = args.pattern;
496
534
  if (!pattern) return { success: false, output: 'pattern required', _tool: 'grep' };
497
535
 
498
- const searchPath = args.path ? resolvePath(args.path) : PROJECT_CWD;
536
+ const searchPath = resolvePath(args.path || null, args);
499
537
  const includeFlag = args.include ? `--glob "${args.include}"` : '';
500
538
 
501
539
  try {
502
540
  const cmd = `rg -n -C 2 --max-count 10 --max-filesize 500K ${includeFlag} -e ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)} 2>/dev/null | head -80`;
503
- const output = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: PROJECT_CWD }).trim();
541
+ const output = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: searchPath }).trim();
504
542
  if (output) {
505
543
  return { success: true, output, _tool: 'grep' };
506
544
  }
@@ -521,7 +559,7 @@ print('OK: replaced')
521
559
  const results = [];
522
560
  for (const p of paths) {
523
561
  try {
524
- const filePath = resolvePath(p);
562
+ const filePath = resolvePath(p, args);
525
563
  const content = fs.readFileSync(filePath, 'utf-8');
526
564
  const lines = content.split('\n').length;
527
565
 
@@ -547,12 +585,13 @@ print('OK: replaced')
547
585
  // 9. delete_file + safety check
548
586
  delete_file: async (args) => {
549
587
  try {
550
- const filePath = resolvePath(args.file_path || args.path);
551
- const delCheck = validateDelete(filePath);
588
+ const filePath = resolvePath(args.file_path || args.path, args);
589
+ const delCheck = validateDelete(filePath, projectRootFor(filePath));
552
590
  if (!delCheck.safe) {
553
591
  return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
554
592
  }
555
593
  fs.unlinkSync(filePath);
594
+ updateProjectIndex(filePath);
556
595
  return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
557
596
  } catch (err) {
558
597
  return { success: false, output: `Error: ${err.message}`, _tool: 'delete_file' };
@@ -562,7 +601,7 @@ print('OK: replaced')
562
601
  // 10. get_file_info
563
602
  get_file_info: async (args) => {
564
603
  try {
565
- const filePath = resolvePath(args.file_path || args.path);
604
+ const filePath = resolvePath(args.file_path || args.path, args);
566
605
  const stat = fs.statSync(filePath);
567
606
  return {
568
607
  success: true,
@@ -580,14 +619,14 @@ print('OK: replaced')
580
619
  // 11. validate_file (syntax check)
581
620
  validate_file: async (args) => {
582
621
  try {
583
- const filePath = resolvePath(args.path);
622
+ const filePath = resolvePath(args.path, args);
584
623
  const ext = path.extname(filePath);
585
624
  let cmd;
586
625
  if (ext === '.py') cmd = `python3 -m py_compile "${filePath}"`;
587
626
  else if (ext === '.js' || ext === '.mjs') cmd = `node --check "${filePath}"`;
588
627
  else return { success: true, valid: true, message: 'No validator for this file type', _tool: 'validate_file' };
589
628
 
590
- execSync(cmd, { stdio: 'pipe' });
629
+ execSync(cmd, { stdio: 'pipe', cwd: projectRootFor(filePath) });
591
630
  return { success: true, valid: true, _tool: 'validate_file' };
592
631
  } catch (err) {
593
632
  return { success: true, valid: false, errors: err.stderr?.toString() || err.message, _tool: 'validate_file' };
@@ -599,12 +638,13 @@ print('OK: replaced')
599
638
  try {
600
639
  let cmd = args.command;
601
640
  if (!cmd) {
602
- if (fs.existsSync('package.json')) cmd = 'npm run build';
603
- else if (fs.existsSync('Makefile')) cmd = 'make';
604
- else if (fs.existsSync('Cargo.toml')) cmd = 'cargo build';
641
+ const cwd = commandCwd(args);
642
+ if (fs.existsSync(path.join(cwd, 'package.json'))) cmd = 'npm run build';
643
+ else if (fs.existsSync(path.join(cwd, 'Makefile'))) cmd = 'make';
644
+ else if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) cmd = 'cargo build';
605
645
  else return { success: false, output: 'No build system detected', _tool: 'validate_build' };
606
646
  }
607
- const output = execSync(cmd, { stdio: 'pipe', timeout: 120_000 }).toString();
647
+ const output = execSync(cmd, { stdio: 'pipe', timeout: 120_000, cwd: commandCwd(args) }).toString();
608
648
  return { success: true, output, _tool: 'validate_build' };
609
649
  } catch (err) {
610
650
  return { success: false, output: err.stderr?.toString() || err.message, _tool: 'validate_build' };
@@ -614,7 +654,9 @@ print('OK: replaced')
614
654
  // 13. validate_structure
615
655
  validate_structure: async (args) => {
616
656
  const expected = args.expected || [];
617
- const missing = expected.filter(f => !fs.existsSync(resolvePath(f)));
657
+ const missing = expected.filter(f =>
658
+ !fs.existsSync(resolvePath(f, args, { allowMissing: true }))
659
+ );
618
660
  return {
619
661
  success: missing.length === 0,
620
662
  missing,
@@ -626,14 +668,14 @@ print('OK: replaced')
626
668
  // 14. lint_check
627
669
  lint_check: async (args) => {
628
670
  try {
629
- const filePath = resolvePath(args.file_path || args.path);
671
+ const filePath = resolvePath(args.file_path || args.path, args);
630
672
  const ext = path.extname(filePath);
631
673
  let cmd;
632
674
  if (ext === '.py') cmd = `python3 -m ruff check "${filePath}" 2>&1 || true`;
633
675
  else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
634
676
  else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
635
677
 
636
- const output = execSync(cmd, { stdio: 'pipe', timeout: 30_000 }).toString();
678
+ const output = execSync(cmd, { stdio: 'pipe', timeout: 30_000, cwd: projectRootFor(filePath) }).toString();
637
679
  return { success: true, output, issues: output.split('\n').filter(Boolean), _tool: 'lint_check' };
638
680
  } catch (err) {
639
681
  return { success: false, output: err.message, _tool: 'lint_check' };
@@ -645,7 +687,7 @@ print('OK: replaced')
645
687
  try {
646
688
  const cmd = args.command || 'npm test';
647
689
  const output = execSync(cmd, {
648
- stdio: 'pipe', timeout: 120_000, cwd: process.cwd(),
690
+ stdio: 'pipe', timeout: 120_000, cwd: commandCwd(args),
649
691
  encoding: 'utf-8',
650
692
  }).toString();
651
693
  return { success: true, output: output.slice(-3000), _tool: 'run_tests' };
@@ -660,7 +702,7 @@ print('OK: replaced')
660
702
  try {
661
703
  const filePath = args.file_path ? `-- "${args.file_path}"` : '';
662
704
  const output = execSync(`git diff ${filePath}`, {
663
- stdio: 'pipe', timeout: 10_000, cwd: process.cwd(), encoding: 'utf-8',
705
+ stdio: 'pipe', timeout: 10_000, cwd: commandCwd(args), encoding: 'utf-8',
664
706
  }).toString();
665
707
  return { success: true, output: output.slice(-5000) || '(no changes)', _tool: 'git_diff' };
666
708
  } catch (err) {
@@ -672,7 +714,7 @@ print('OK: replaced')
672
714
  git_status: async (args) => {
673
715
  try {
674
716
  const output = execSync('git status --short', {
675
- stdio: 'pipe', timeout: 10_000, cwd: process.cwd(), encoding: 'utf-8',
717
+ stdio: 'pipe', timeout: 10_000, cwd: commandCwd(args), encoding: 'utf-8',
676
718
  }).toString();
677
719
  return { success: true, output: output || '(clean)', _tool: 'git_status' };
678
720
  } catch (err) {
@@ -684,7 +726,7 @@ print('OK: replaced')
684
726
  // Returns function signatures, classes, imports instead of raw file contents
685
727
  // 10x more token-efficient than read_file
686
728
  analyze_code: async (args) => {
687
- const filePath = resolvePath(args.file_path || args.path);
729
+ const filePath = resolvePath(args.file_path || args.path, args);
688
730
  const result = analyzeCode(filePath, {
689
731
  startLine: args.start_line,
690
732
  endLine: args.end_line,
@@ -696,6 +738,49 @@ print('OK: replaced')
696
738
  _tool: 'analyze_code',
697
739
  };
698
740
  },
741
+
742
+ // Project overview — on-demand index + skeleton
743
+ get_project_overview: async (args) => {
744
+ const projectPath = args.path || args.project_path;
745
+ const result = await projectRegistry.register(projectPath);
746
+ return {
747
+ success: true,
748
+ output: result.output,
749
+ project_resource: result.resource,
750
+ already_registered: result.already_registered,
751
+ _tool: 'get_project_overview',
752
+ };
753
+ },
754
+
755
+ // Portable skills — metadata first, full content only on demand.
756
+ skills_list: async (args) => ({
757
+ success: true,
758
+ output: JSON.stringify(skillsLoader.list({
759
+ query: args.query || '',
760
+ source: args.source || '',
761
+ scope: args.scope || '',
762
+ }), null, 2),
763
+ skills: skillsLoader.list({
764
+ query: args.query || '',
765
+ source: args.source || '',
766
+ scope: args.scope || '',
767
+ }),
768
+ _tool: 'skills_list',
769
+ }),
770
+
771
+ skill_view: async (args) => {
772
+ const skill = skillsLoader.view(
773
+ args.name,
774
+ args.path || null,
775
+ { sourceId: args.source_id || null },
776
+ );
777
+ return {
778
+ success: true,
779
+ output: JSON.stringify(skill, null, 2),
780
+ skill,
781
+ _tool: 'skill_view',
782
+ };
783
+ },
699
784
  };
700
785
 
701
786
  return {
@@ -721,5 +806,28 @@ print('OK: replaced')
721
806
  listTools() {
722
807
  return Object.keys(toolMap);
723
808
  },
809
+
810
+ getProjectResources() {
811
+ return projectRegistry.resources();
812
+ },
813
+
814
+ getAgentContext() {
815
+ const global = projectRegistry.getGlobalContext();
816
+ return {
817
+ identity: global.identity,
818
+ preferences: global.preferences,
819
+ global_skills: skillsLoader.list(),
820
+ source: 'cli',
821
+ };
822
+ },
823
+
824
+ reloadSkills(cwd = process.cwd()) {
825
+ skillsLoader.load(cwd);
826
+ return skillsLoader.list();
827
+ },
828
+
829
+ resetProjects() {
830
+ projectRegistry.reset();
831
+ },
724
832
  };
725
833
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Portable skill installation and lock metadata.
3
+ *
4
+ * Bundles are copied as data. No scripts or hooks from a skill are executed.
5
+ */
6
+
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { discoverSkillDirectories, parseSkill } from './loader.mjs';
13
+
14
+ function isGitSource(source) {
15
+ return /^(https?:\/\/|ssh:\/\/|git@|github:)/.test(source) || source.endsWith('.git');
16
+ }
17
+
18
+ function normalizeGitSource(source) {
19
+ if (source.startsWith('github:')) {
20
+ return `https://github.com/${source.slice('github:'.length).replace(/\.git$/, '')}.git`;
21
+ }
22
+ return source;
23
+ }
24
+
25
+ function discoverInstallableSkills(root, maxDepth = 4) {
26
+ const direct = discoverSkillDirectories(root);
27
+ if (direct.length) return direct;
28
+
29
+ const found = [];
30
+ const queue = [{ dir: root, depth: 0 }];
31
+ const ignored = new Set(['.git', 'node_modules', '.venv', 'venv', 'dist', 'build']);
32
+ while (queue.length) {
33
+ const { dir, depth } = queue.shift();
34
+ if (depth >= maxDepth) continue;
35
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
36
+ if (!entry.isDirectory() || entry.isSymbolicLink() || ignored.has(entry.name)) continue;
37
+ const child = path.join(dir, entry.name);
38
+ if (fs.existsSync(path.join(child, 'SKILL.md'))) {
39
+ found.push(child);
40
+ } else {
41
+ queue.push({ dir: child, depth: depth + 1 });
42
+ }
43
+ }
44
+ }
45
+ return found.sort();
46
+ }
47
+
48
+ function rejectSymlinks(root) {
49
+ const queue = [root];
50
+ while (queue.length) {
51
+ const current = queue.shift();
52
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
53
+ const fullPath = path.join(current, entry.name);
54
+ if (entry.isSymbolicLink()) throw new Error(`Symlinked skill resource is not allowed: ${fullPath}`);
55
+ if (entry.isDirectory()) queue.push(fullPath);
56
+ }
57
+ }
58
+ }
59
+
60
+ function copyDirectory(source, destination) {
61
+ fs.mkdirSync(destination, { recursive: true });
62
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
63
+ const from = path.join(source, entry.name);
64
+ const to = path.join(destination, entry.name);
65
+ if (entry.isDirectory()) copyDirectory(from, to);
66
+ else if (entry.isFile()) fs.copyFileSync(from, to);
67
+ }
68
+ }
69
+
70
+ function readJson(file, fallback) {
71
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return fallback; }
72
+ }
73
+
74
+ function writeJson(file, value) {
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
77
+ }
78
+
79
+ export class SkillInstaller {
80
+ constructor({ cwd = process.cwd(), homeDir = os.homedir() } = {}) {
81
+ this.cwd = path.resolve(cwd);
82
+ this.homeDir = homeDir;
83
+ }
84
+
85
+ paths(scope = 'global') {
86
+ const base = scope === 'project'
87
+ ? path.join(this.cwd, '.kepler')
88
+ : path.join(this.homeDir, '.kepler');
89
+ return {
90
+ skillsDir: path.join(base, 'skills'),
91
+ lockFile: path.join(base, 'skills.lock.json'),
92
+ };
93
+ }
94
+
95
+ install(source, { scope = 'global', force = false, onlyNames = null } = {}) {
96
+ if (!source) throw new Error('A local directory or Git repository is required');
97
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kepler-skill-'));
98
+ let resolvedSource;
99
+ let commit = null;
100
+ try {
101
+ if (isGitSource(source)) {
102
+ resolvedSource = path.join(tempRoot, 'repository');
103
+ execFileSync(
104
+ 'git',
105
+ ['clone', '--depth', '1', normalizeGitSource(source), resolvedSource],
106
+ { stdio: 'pipe' },
107
+ );
108
+ commit = execFileSync('git', ['-C', resolvedSource, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
109
+ } else {
110
+ resolvedSource = fs.realpathSync(path.resolve(this.cwd, source));
111
+ }
112
+
113
+ const skillDirs = discoverInstallableSkills(resolvedSource);
114
+ if (!skillDirs.length) throw new Error(`No SKILL.md bundles found in ${source}`);
115
+
116
+ const { skillsDir, lockFile } = this.paths(scope);
117
+ const lock = readJson(lockFile, { version: 1, skills: {} });
118
+ const installed = [];
119
+ fs.mkdirSync(skillsDir, { recursive: true });
120
+
121
+ const plans = [];
122
+ for (const skillDir of skillDirs) {
123
+ rejectSymlinks(skillDir);
124
+ const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
125
+ const name = parseSkill(content, path.basename(skillDir)).name;
126
+ if (onlyNames && !onlyNames.includes(name)) continue;
127
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`Unsafe skill name: ${name}`);
128
+ const destination = path.join(skillsDir, name);
129
+ if (fs.existsSync(destination)) {
130
+ if (!force) throw new Error(`Skill already installed: ${name} (use --force to replace)`);
131
+ }
132
+ plans.push({ skillDir, content, name, destination });
133
+ }
134
+ if (!plans.length) {
135
+ throw new Error(
136
+ onlyNames
137
+ ? `Requested skill not found in source: ${onlyNames.join(', ')}`
138
+ : `No installable skills found in ${source}`,
139
+ );
140
+ }
141
+
142
+ for (const { skillDir, content, name, destination } of plans) {
143
+ if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
144
+ copyDirectory(skillDir, destination);
145
+
146
+ const digest = crypto.createHash('sha256')
147
+ .update(content)
148
+ .digest('hex');
149
+ lock.skills[name] = {
150
+ source,
151
+ scope,
152
+ commit,
153
+ destination,
154
+ installed_at: new Date().toISOString(),
155
+ manifest_hash: `sha256:${digest}`,
156
+ };
157
+ installed.push(name);
158
+ }
159
+ writeJson(lockFile, lock);
160
+ return { installed, scope, lock_file: lockFile };
161
+ } finally {
162
+ fs.rmSync(tempRoot, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ remove(name, { scope = 'global' } = {}) {
167
+ const { skillsDir, lockFile } = this.paths(scope);
168
+ const destination = path.join(skillsDir, name);
169
+ if (!fs.existsSync(destination)) throw new Error(`Skill is not installed: ${name}`);
170
+ fs.rmSync(destination, { recursive: true, force: true });
171
+ const lock = readJson(lockFile, { version: 1, skills: {} });
172
+ delete lock.skills[name];
173
+ writeJson(lockFile, lock);
174
+ return { removed: name, scope };
175
+ }
176
+
177
+ update(name, { scope = 'global' } = {}) {
178
+ const { lockFile } = this.paths(scope);
179
+ const lock = readJson(lockFile, { version: 1, skills: {} });
180
+ const entry = lock.skills[name];
181
+ if (!entry?.source) throw new Error(`No locked source for skill: ${name}`);
182
+ return this.install(entry.source, { scope, force: true, onlyNames: [name] });
183
+ }
184
+
185
+ lock(scope = 'global') {
186
+ return readJson(this.paths(scope).lockFile, { version: 1, skills: {} });
187
+ }
188
+ }