@axplusb/kepler 1.0.4 → 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.
- package/KEPLER-README.md +128 -2
- package/package.json +4 -4
- package/pulse/app/activity/page.tsx +1 -1
- package/pulse/app/api/import/route.ts +1 -1
- package/pulse/app/api/memory/route.ts +2 -2
- package/pulse/app/costs/page.tsx +1 -1
- package/pulse/app/export/page.tsx +3 -3
- package/pulse/app/globals.css +3 -3
- package/pulse/app/help/page.tsx +11 -11
- package/pulse/app/history/page.tsx +2 -2
- package/pulse/app/layout.tsx +2 -2
- package/pulse/app/memory/page.tsx +2 -2
- package/pulse/app/overview-client.tsx +1 -1
- package/pulse/app/page.tsx +2 -2
- package/pulse/app/plans/page.tsx +2 -2
- package/pulse/app/projects/page.tsx +1 -1
- package/pulse/app/sessions/page.tsx +1 -1
- package/pulse/app/settings/page.tsx +4 -4
- package/pulse/app/todos/page.tsx +2 -2
- package/pulse/app/tools/page.tsx +1 -1
- package/pulse/cli.js +15 -25
- package/pulse/components/layout/sidebar.tsx +2 -2
- package/pulse/components/sessions/replay/user-tool-result.tsx +1 -1
- package/pulse/lib/claude-reader.ts +1 -1
- package/pulse/lib/decode.ts +1 -1
- package/pulse/package.json +3 -3
- package/src/auth/tarang-auth.mjs +1 -1
- package/src/config/cli-args.mjs +5 -0
- package/src/context/retriever.mjs +1 -1
- package/src/context/skeleton.mjs +1 -1
- package/src/core/approval.mjs +22 -53
- package/src/core/headless.mjs +68 -24
- package/src/core/paths.mjs +1 -1
- package/src/core/project-artifacts.mjs +37 -0
- package/src/core/stream-client.mjs +6 -1
- package/src/core/tool-executor.mjs +163 -55
- package/src/skills/installer.mjs +188 -0
- package/src/skills/loader.mjs +217 -112
- package/src/terminal/main.mjs +19 -1
- package/src/terminal/repl.mjs +40 -105
- package/src/terminal/skills.mjs +54 -0
- package/src/terminal/tool-display.mjs +82 -0
- package/src/tools/bash.mjs +5 -2
- package/src/tools/project-overview.mjs +418 -0
- package/src/tools/registry.mjs +0 -16
- package/src/ui/banner.mjs +7 -14
- package/src/ui/formatter.mjs +6 -40
- package/README.md.orca +0 -82
|
@@ -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
|
-
*
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
32
|
-
|
|
37
|
+
function resolvePath(p, args = {}, options = {}) {
|
|
38
|
+
return projectRegistry.resolvePath(p, args.project_id, options);
|
|
39
|
+
}
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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}.
|
|
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(
|
|
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 || ''}".
|
|
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, {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
else if (fs.existsSync(
|
|
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 =>
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|