skimpyclaw 0.1.8 → 0.2.0
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/dist/__tests__/bash-path-validation.test.d.ts +1 -0
- package/dist/__tests__/bash-path-validation.test.js +164 -0
- package/dist/__tests__/doctor.runner.test.js +5 -1
- package/dist/__tests__/heartbeat.test.js +30 -3
- package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
- package/dist/__tests__/sandbox-bridge.test.js +116 -0
- package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
- package/dist/__tests__/sandbox-manager.test.js +119 -0
- package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
- package/dist/__tests__/sandbox-mount-security.test.js +131 -0
- package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
- package/dist/__tests__/sandbox-runtime.test.js +140 -0
- package/dist/__tests__/setup.test.js +32 -3
- package/dist/__tests__/skills.test.js +2 -11
- package/dist/__tests__/tools.test.js +6 -1
- package/dist/__tests__/voice.test.js +12 -0
- package/dist/agent.js +2 -0
- package/dist/api.js +5 -1
- package/dist/channels/telegram/utils.js +2 -2
- package/dist/cli.js +212 -0
- package/dist/code-agents/executor.js +17 -4
- package/dist/code-agents/types.d.ts +5 -0
- package/dist/cron.js +16 -2
- package/dist/discord.js +2 -2
- package/dist/doctor/checks.d.ts +1 -0
- package/dist/doctor/checks.js +47 -0
- package/dist/doctor/runner.js +2 -1
- package/dist/exec-approval.d.ts +4 -0
- package/dist/exec-approval.js +4 -4
- package/dist/gateway.js +33 -2
- package/dist/heartbeat.js +7 -3
- package/dist/providers/openai.js +1 -1
- package/dist/sandbox/bridge.d.ts +5 -0
- package/dist/sandbox/bridge.js +63 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/manager.d.ts +7 -0
- package/dist/sandbox/manager.js +89 -0
- package/dist/sandbox/mount-security.d.ts +12 -0
- package/dist/sandbox/mount-security.js +118 -0
- package/dist/sandbox/runtime.d.ts +33 -0
- package/dist/sandbox/runtime.js +167 -0
- package/dist/service.js +17 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +336 -23
- package/dist/skills.d.ts +1 -2
- package/dist/skills.js +1 -13
- package/dist/tools/bash-path-validation.d.ts +22 -0
- package/dist/tools/bash-path-validation.js +130 -0
- package/dist/tools/bash-tool.js +23 -1
- package/dist/tools/definitions.d.ts +0 -7
- package/dist/tools/definitions.js +0 -5
- package/dist/tools/execute-context.d.ts +4 -0
- package/dist/tools/path-utils.js +16 -2
- package/dist/tools.js +84 -2
- package/dist/types.d.ts +10 -0
- package/dist/voice.js +5 -1
- package/package.json +1 -1
package/dist/setup.js
CHANGED
|
@@ -56,6 +56,66 @@ function loadExistingSetup() {
|
|
|
56
56
|
}
|
|
57
57
|
return { config, env };
|
|
58
58
|
}
|
|
59
|
+
function detectSandboxRuntime(preferred) {
|
|
60
|
+
if (preferred) {
|
|
61
|
+
const check = spawnSync(preferred, ['--version'], { encoding: 'utf-8' });
|
|
62
|
+
return check.status === 0 ? preferred : null;
|
|
63
|
+
}
|
|
64
|
+
const containerCheck = spawnSync('container', ['--version'], { encoding: 'utf-8' });
|
|
65
|
+
if (containerCheck.status === 0)
|
|
66
|
+
return 'container';
|
|
67
|
+
const dockerCheck = spawnSync('docker', ['--version'], { encoding: 'utf-8' });
|
|
68
|
+
if (dockerCheck.status === 0)
|
|
69
|
+
return 'docker';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function sandboxRuntimeRunning(runtime) {
|
|
73
|
+
if (runtime === 'container') {
|
|
74
|
+
return spawnSync('container', ['system', 'status'], { encoding: 'utf-8' }).status === 0;
|
|
75
|
+
}
|
|
76
|
+
return spawnSync('docker', ['info'], { encoding: 'utf-8' }).status === 0;
|
|
77
|
+
}
|
|
78
|
+
function sandboxNetworkExists(runtime, network) {
|
|
79
|
+
if (runtime === 'container') {
|
|
80
|
+
const result = spawnSync('container', ['network', 'ls'], { encoding: 'utf-8' });
|
|
81
|
+
if (result.status !== 0)
|
|
82
|
+
return false;
|
|
83
|
+
return result.stdout
|
|
84
|
+
.split('\n')
|
|
85
|
+
.some((line) => line.trim().split(/\s+/)[0] === network);
|
|
86
|
+
}
|
|
87
|
+
return spawnSync('docker', ['network', 'inspect', network], { encoding: 'utf-8' }).status === 0;
|
|
88
|
+
}
|
|
89
|
+
function defaultSandboxNetwork(runtime) {
|
|
90
|
+
return runtime === 'container' ? 'default' : 'bridge';
|
|
91
|
+
}
|
|
92
|
+
function bootstrapSandbox(runtime, image, network) {
|
|
93
|
+
const sandboxDir = join(__dirname, '..', 'sandbox');
|
|
94
|
+
const dockerfile = join(sandboxDir, 'Dockerfile');
|
|
95
|
+
if (!existsSync(dockerfile)) {
|
|
96
|
+
return { ok: false, message: `Sandbox Dockerfile not found: ${dockerfile}` };
|
|
97
|
+
}
|
|
98
|
+
if (!sandboxRuntimeRunning(runtime)) {
|
|
99
|
+
const hint = runtime === 'container'
|
|
100
|
+
? 'Run `container system start` and rerun onboarding.'
|
|
101
|
+
: 'Start Docker Desktop and rerun onboarding.';
|
|
102
|
+
return { ok: false, message: `Runtime "${runtime}" is not running. ${hint}` };
|
|
103
|
+
}
|
|
104
|
+
if (!sandboxNetworkExists(runtime, network)) {
|
|
105
|
+
return { ok: false, message: `Network "${network}" not found for ${runtime}. Update sandbox.network and run \`skimpyclaw sandbox init\`.` };
|
|
106
|
+
}
|
|
107
|
+
const build = spawnSync(runtime, ['build', '--build-arg', 'SKIMPY_PROFILE=minimal', '-t', image, sandboxDir], { encoding: 'utf-8' });
|
|
108
|
+
if (build.status !== 0) {
|
|
109
|
+
const detail = `${build.stderr || ''}\n${build.stdout || ''}`.trim();
|
|
110
|
+
return { ok: false, message: `Image build failed: ${detail.slice(-800)}` };
|
|
111
|
+
}
|
|
112
|
+
const smoke = spawnSync(runtime, ['run', '--rm', '--network', network, image, 'sh', '-lc', 'hostname && command -v gh >/dev/null && command -v rg >/dev/null && echo sandbox-ok'], { encoding: 'utf-8' });
|
|
113
|
+
if (smoke.status !== 0) {
|
|
114
|
+
const detail = `${smoke.stderr || ''}\n${smoke.stdout || ''}`.trim();
|
|
115
|
+
return { ok: false, message: `Sandbox smoke test failed: ${detail.slice(-800)}` };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, message: (smoke.stdout || '').trim().split('\n').join(' | ') };
|
|
118
|
+
}
|
|
59
119
|
function ask(rl, question) {
|
|
60
120
|
return new Promise((resolve) => {
|
|
61
121
|
rl.question(question, (answer) => {
|
|
@@ -162,6 +222,40 @@ async function askProviders(rl, existingProviders) {
|
|
|
162
222
|
return choices;
|
|
163
223
|
}
|
|
164
224
|
}
|
|
225
|
+
function buildStarterCronJobs(starters) {
|
|
226
|
+
const jobs = [];
|
|
227
|
+
if (starters.cronTechNews) {
|
|
228
|
+
jobs.push({
|
|
229
|
+
id: 'starter-tech-news-hn',
|
|
230
|
+
name: 'Tech News — Top 10 HN',
|
|
231
|
+
schedule: {
|
|
232
|
+
kind: 'cron',
|
|
233
|
+
expr: '0 8 * * *',
|
|
234
|
+
tz: starters.timezone,
|
|
235
|
+
},
|
|
236
|
+
payload: {
|
|
237
|
+
kind: 'agentTurn',
|
|
238
|
+
message: 'Use WebSearch to fetch today\'s top 10 Hacker News stories. Reply with title, URL, and 1-line summary for each item.',
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (starters.cronWeather) {
|
|
243
|
+
jobs.push({
|
|
244
|
+
id: 'starter-weather-7am',
|
|
245
|
+
name: 'Weather Check — 7:00 AM',
|
|
246
|
+
schedule: {
|
|
247
|
+
kind: 'cron',
|
|
248
|
+
expr: '0 7 * * *',
|
|
249
|
+
tz: starters.timezone,
|
|
250
|
+
},
|
|
251
|
+
payload: {
|
|
252
|
+
kind: 'agentTurn',
|
|
253
|
+
message: `Check current weather and today forecast for ${starters.weatherLocation}. Keep it concise: current temp/conditions, highs/lows, precipitation chance, and 1 recommendation.`,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return jobs;
|
|
258
|
+
}
|
|
165
259
|
async function collectProviderSecrets(rl, providers, existingEnv) {
|
|
166
260
|
const secrets = {};
|
|
167
261
|
const env = existingEnv || {};
|
|
@@ -346,7 +440,23 @@ function buildEnvContent(telegramToken, providers, secrets, discordToken) {
|
|
|
346
440
|
}
|
|
347
441
|
export function buildSetupConfig(input) {
|
|
348
442
|
const useDiscord = Boolean(input.discordToken);
|
|
349
|
-
const features = input.features ?? { browser: false, voice: false, mcp: false };
|
|
443
|
+
const features = input.features ?? { browser: false, voice: false, mcp: false, sandbox: false };
|
|
444
|
+
const starters = input.starters ?? {
|
|
445
|
+
cronTechNews: false,
|
|
446
|
+
cronWeather: false,
|
|
447
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
|
448
|
+
weatherLocation: 'New York, NY',
|
|
449
|
+
skillCodeReview: false,
|
|
450
|
+
skillDailyNotes: false,
|
|
451
|
+
};
|
|
452
|
+
const basePaths = ['${HOME}/.skimpyclaw'];
|
|
453
|
+
const allPaths = [...basePaths, ...(input.extraAllowedPaths || [])];
|
|
454
|
+
const starterCronJobs = buildStarterCronJobs(starters);
|
|
455
|
+
const starterSkillEntries = {};
|
|
456
|
+
if (starters.skillCodeReview)
|
|
457
|
+
starterSkillEntries['code-review'] = true;
|
|
458
|
+
if (starters.skillDailyNotes)
|
|
459
|
+
starterSkillEntries['daily-notes'] = true;
|
|
350
460
|
return {
|
|
351
461
|
gateway: {
|
|
352
462
|
port: 18790,
|
|
@@ -376,35 +486,26 @@ export function buildSetupConfig(input) {
|
|
|
376
486
|
enabled: true,
|
|
377
487
|
token: '${TELEGRAM_BOT_TOKEN}',
|
|
378
488
|
allowFrom: [parseInt(input.telegramId, 10) || input.telegramId],
|
|
379
|
-
dailyNotesDir: '${HOME}/Daily Notes',
|
|
380
|
-
defaultAllowedPaths:
|
|
381
|
-
'${HOME}/.skimpyclaw',
|
|
382
|
-
input.workspaceDir,
|
|
383
|
-
],
|
|
489
|
+
dailyNotesDir: '${HOME}/.skimpyclaw/Daily Notes',
|
|
490
|
+
defaultAllowedPaths: allPaths,
|
|
384
491
|
},
|
|
385
492
|
discord: {
|
|
386
493
|
enabled: useDiscord,
|
|
387
494
|
token: useDiscord ? '${DISCORD_BOT_TOKEN}' : '',
|
|
388
495
|
allowFrom: useDiscord ? [input.discordUserId || ''] : [],
|
|
389
|
-
defaultAllowedPaths:
|
|
390
|
-
'${HOME}/.skimpyclaw',
|
|
391
|
-
input.workspaceDir,
|
|
392
|
-
],
|
|
496
|
+
defaultAllowedPaths: allPaths,
|
|
393
497
|
...(input.discordDefaultChannelId ? { defaultChannelId: input.discordDefaultChannelId } : {}),
|
|
394
498
|
},
|
|
395
499
|
},
|
|
396
500
|
cron: {
|
|
397
|
-
jobs:
|
|
501
|
+
jobs: starterCronJobs,
|
|
398
502
|
},
|
|
399
503
|
heartbeat: {
|
|
400
504
|
intervalMs: 1800000,
|
|
401
505
|
prompt: 'Read ~/.skimpyclaw/agents/main/HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
|
|
402
506
|
tools: {
|
|
403
507
|
enabled: true,
|
|
404
|
-
allowedPaths:
|
|
405
|
-
'${HOME}/.skimpyclaw',
|
|
406
|
-
input.workspaceDir,
|
|
407
|
-
],
|
|
508
|
+
allowedPaths: allPaths,
|
|
408
509
|
maxIterations: 10,
|
|
409
510
|
bashTimeout: 15000,
|
|
410
511
|
...(features.browser ? { browser: { enabled: true } } : { browser: { enabled: false } }),
|
|
@@ -423,6 +524,22 @@ export function buildSetupConfig(input) {
|
|
|
423
524
|
},
|
|
424
525
|
},
|
|
425
526
|
} : {}),
|
|
527
|
+
...(features.sandbox ? {
|
|
528
|
+
sandbox: {
|
|
529
|
+
enabled: true,
|
|
530
|
+
image: 'skimpyclaw-sandbox',
|
|
531
|
+
cpus: 2,
|
|
532
|
+
memory: '2G',
|
|
533
|
+
network: 'bridge',
|
|
534
|
+
idleTimeoutMs: 3600000,
|
|
535
|
+
},
|
|
536
|
+
} : {}),
|
|
537
|
+
...(Object.keys(starterSkillEntries).length > 0 ? {
|
|
538
|
+
skills: {
|
|
539
|
+
enabled: true,
|
|
540
|
+
entries: starterSkillEntries,
|
|
541
|
+
},
|
|
542
|
+
} : {}),
|
|
426
543
|
dashboard: {
|
|
427
544
|
token: randomUUID(),
|
|
428
545
|
},
|
|
@@ -442,6 +559,34 @@ const REQUIRED_TEMPLATE_DEFAULTS = {
|
|
|
442
559
|
'USER.md': '# USER\n\nName: User\n',
|
|
443
560
|
'HEARTBEAT.md': '# HEARTBEAT\n\nIf nothing needs attention, reply HEARTBEAT_OK.\n',
|
|
444
561
|
};
|
|
562
|
+
const STARTER_SKILL_TEMPLATES = {
|
|
563
|
+
'code-review': `---
|
|
564
|
+
name: code-review
|
|
565
|
+
description: Structured code review checklist for bugs, regressions, and missing tests.
|
|
566
|
+
triggers: ["review", "pr", "regression", "tests"]
|
|
567
|
+
priority: 80
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
When asked to review code:
|
|
571
|
+
1. Focus on correctness and regressions first.
|
|
572
|
+
2. Call out missing or weak test coverage.
|
|
573
|
+
3. Prefer concrete file-level findings.
|
|
574
|
+
4. End with risk summary and recommended fixes.
|
|
575
|
+
`,
|
|
576
|
+
'daily-notes': `---
|
|
577
|
+
name: daily-notes
|
|
578
|
+
description: Keep daily notes organized under the configured daily notes directory.
|
|
579
|
+
triggers: ["daily note", "standup", "plan day", "journal"]
|
|
580
|
+
priority: 90
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
When writing daily notes:
|
|
584
|
+
1. Use today's date in the file name if missing.
|
|
585
|
+
2. Include sections: Priorities, Schedule, Notes, Follow-ups.
|
|
586
|
+
3. Keep entries concise and actionable.
|
|
587
|
+
4. Avoid creating files outside the configured daily notes directory.
|
|
588
|
+
`,
|
|
589
|
+
};
|
|
445
590
|
function ensureCoreTemplates(agentDir) {
|
|
446
591
|
const created = [];
|
|
447
592
|
for (const [file, content] of Object.entries(REQUIRED_TEMPLATE_DEFAULTS)) {
|
|
@@ -453,6 +598,26 @@ function ensureCoreTemplates(agentDir) {
|
|
|
453
598
|
}
|
|
454
599
|
return created;
|
|
455
600
|
}
|
|
601
|
+
function ensureStarterSkills(starters) {
|
|
602
|
+
const created = [];
|
|
603
|
+
const skillsDir = join(CONFIG_DIR, 'skills');
|
|
604
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
605
|
+
const requested = [];
|
|
606
|
+
if (starters.skillCodeReview)
|
|
607
|
+
requested.push('code-review');
|
|
608
|
+
if (starters.skillDailyNotes)
|
|
609
|
+
requested.push('daily-notes');
|
|
610
|
+
for (const skillName of requested) {
|
|
611
|
+
const dir = join(skillsDir, skillName);
|
|
612
|
+
const skillPath = join(dir, 'SKILL.md');
|
|
613
|
+
if (!existsSync(skillPath)) {
|
|
614
|
+
mkdirSync(dir, { recursive: true });
|
|
615
|
+
writeFileSync(skillPath, STARTER_SKILL_TEMPLATES[skillName], 'utf-8');
|
|
616
|
+
created.push(skillName);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return created;
|
|
620
|
+
}
|
|
456
621
|
async function quickFetch(url, init) {
|
|
457
622
|
return await fetch(url, { ...init, signal: AbortSignal.timeout(12000) });
|
|
458
623
|
}
|
|
@@ -535,8 +700,11 @@ export async function runSetup(options = {}) {
|
|
|
535
700
|
if (templates.length === 0) {
|
|
536
701
|
throw new Error(`No markdown templates found in ${TEMPLATES_DIR}`);
|
|
537
702
|
}
|
|
538
|
-
// Validate launchd template
|
|
539
|
-
|
|
703
|
+
// Validate launchd template exists. Full render validation requires built dist/
|
|
704
|
+
// which may not be present in dry-run contexts (e.g. CI test-only jobs).
|
|
705
|
+
if (!existsSync(GATEWAY_PLIST_TEMPLATE)) {
|
|
706
|
+
throw new Error(`Gateway launchd template not found: ${GATEWAY_PLIST_TEMPLATE}`);
|
|
707
|
+
}
|
|
540
708
|
console.log('✅ Onboarding dry run successful.');
|
|
541
709
|
console.log(`Would create config under: ${CONFIG_DIR}`);
|
|
542
710
|
console.log(`Would copy ${templates.length} templates to: ${AGENTS_DIR}`);
|
|
@@ -621,7 +789,42 @@ export async function runSetup(options = {}) {
|
|
|
621
789
|
sectionHeader('5. Your Name');
|
|
622
790
|
const userName = (await ask(rl, ' What should I call you? ')) || 'User';
|
|
623
791
|
statusOk(userName);
|
|
624
|
-
// 6.
|
|
792
|
+
// 6. Workspace Directory
|
|
793
|
+
sectionHeader('6. Workspace Directory');
|
|
794
|
+
console.log(' The agent can read/write files in allowed directories.');
|
|
795
|
+
console.log(' ~/.skimpyclaw is always included. Add project directories here.');
|
|
796
|
+
const existingExtraPaths = existing.config?.channels?.telegram?.defaultAllowedPaths
|
|
797
|
+
?.filter((p) => p !== '${HOME}/.skimpyclaw') || [];
|
|
798
|
+
const existingExtra = existingExtraPaths.join(', ');
|
|
799
|
+
const workspaceDirInput = await ask(rl, ` Additional directory to allow (or Enter to skip)${existingExtra ? ` [${existingExtra}]` : ''}: `);
|
|
800
|
+
const extraAllowedPaths = [];
|
|
801
|
+
if (workspaceDirInput) {
|
|
802
|
+
extraAllowedPaths.push(workspaceDirInput);
|
|
803
|
+
statusOk(`Added: ${workspaceDirInput}`);
|
|
804
|
+
}
|
|
805
|
+
else if (existingExtra) {
|
|
806
|
+
extraAllowedPaths.push(...existingExtraPaths);
|
|
807
|
+
statusOk(`Keeping: ${existingExtra}`);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
statusOk('Only ~/.skimpyclaw (default)');
|
|
811
|
+
}
|
|
812
|
+
// 7. Tool Safety Consent
|
|
813
|
+
sectionHeader('7. Safety Notice');
|
|
814
|
+
console.log(' ⚠ This agent can:');
|
|
815
|
+
console.log(' • Read and write files in allowed directories');
|
|
816
|
+
console.log(' • Run terminal commands (with safety filters and approval gates)');
|
|
817
|
+
console.log(' • Send messages via configured channels (Telegram, Discord)');
|
|
818
|
+
console.log(' • Access MCP tools if configured');
|
|
819
|
+
console.log('');
|
|
820
|
+
const consent = /^y(es)?$/i.test(await ask(rl, ' Do you understand and accept these capabilities? [y/N]: '));
|
|
821
|
+
if (!consent) {
|
|
822
|
+
console.log(`\n${c.red('Setup cancelled.')} Re-run when ready.`);
|
|
823
|
+
rl.close();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
statusOk('Acknowledged');
|
|
827
|
+
// 8. Optional Features
|
|
625
828
|
const existingBrowser = existing.config?.heartbeat?.tools?.browser?.enabled === true
|
|
626
829
|
|| existing.config?.channels?.telegram?.tools?.browser?.enabled === true;
|
|
627
830
|
const existingVoice = existing.config?.voice?.enabled === true;
|
|
@@ -672,13 +875,62 @@ export async function runSetup(options = {}) {
|
|
|
672
875
|
else {
|
|
673
876
|
statusOk('MCP tools disabled');
|
|
674
877
|
}
|
|
878
|
+
// 6d. Sandbox (container isolation)
|
|
879
|
+
const existingSandbox = existing.config?.sandbox?.enabled === true;
|
|
880
|
+
const sandboxDefault = existingSandbox ? 'Y' : 'N';
|
|
881
|
+
const enableSandbox = /^y(es)?$/i.test(await ask(rl, ` Enable sandbox? (requires Docker or Apple Containers) [${existingSandbox ? 'Y/n' : 'y/N'}]: `) || sandboxDefault);
|
|
882
|
+
let detectedSandboxRuntime = null;
|
|
883
|
+
if (enableSandbox) {
|
|
884
|
+
const containerCli = spawnSync('which', ['container'], { encoding: 'utf-8' });
|
|
885
|
+
const docker = spawnSync('which', ['docker'], { encoding: 'utf-8' });
|
|
886
|
+
if (containerCli.status === 0) {
|
|
887
|
+
detectedSandboxRuntime = 'container';
|
|
888
|
+
statusOk('Apple Containers detected');
|
|
889
|
+
}
|
|
890
|
+
else if (docker.status === 0) {
|
|
891
|
+
detectedSandboxRuntime = 'docker';
|
|
892
|
+
statusOk('Docker detected');
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
statusWarn('No container runtime found — sandbox features won\'t work until Docker or Apple Containers is installed');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
statusOk('sandbox disabled');
|
|
900
|
+
}
|
|
675
901
|
const features = {
|
|
676
902
|
browser: enableBrowser,
|
|
677
903
|
voice: enableVoice,
|
|
678
904
|
mcp: enableMcp,
|
|
905
|
+
sandbox: enableSandbox,
|
|
906
|
+
};
|
|
907
|
+
sectionHeader('Starter Packs (optional)');
|
|
908
|
+
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
909
|
+
const addTechNewsCron = /^y(es)?$/i.test(await ask(rl, ' Add starter cron: top 10 Hacker News daily? [y/N]: '));
|
|
910
|
+
const addWeatherCron = /^y(es)?$/i.test(await ask(rl, ' Add starter cron: weather check daily at 7:00am? [y/N]: '));
|
|
911
|
+
let cronTimezone = localTz;
|
|
912
|
+
let weatherLocation = 'New York, NY';
|
|
913
|
+
if (addTechNewsCron || addWeatherCron) {
|
|
914
|
+
const tzInput = await ask(rl, ` Timezone for starter cron jobs [${localTz}]: `);
|
|
915
|
+
cronTimezone = tzInput || localTz;
|
|
916
|
+
}
|
|
917
|
+
if (addWeatherCron) {
|
|
918
|
+
const locationInput = await ask(rl, ' Weather location (city, state/country) [New York, NY]: ');
|
|
919
|
+
weatherLocation = locationInput || 'New York, NY';
|
|
920
|
+
}
|
|
921
|
+
const addCodeReviewSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: code-review? [y/N]: '));
|
|
922
|
+
const addDailyNotesSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: daily-notes? [y/N]: '));
|
|
923
|
+
const starters = {
|
|
924
|
+
cronTechNews: addTechNewsCron,
|
|
925
|
+
cronWeather: addWeatherCron,
|
|
926
|
+
timezone: cronTimezone,
|
|
927
|
+
weatherLocation,
|
|
928
|
+
skillCodeReview: addCodeReviewSkill,
|
|
929
|
+
skillDailyNotes: addDailyNotesSkill,
|
|
679
930
|
};
|
|
680
|
-
const {
|
|
681
|
-
workspaceDir:
|
|
931
|
+
const { envContent, config: generatedConfig } = buildSetupArtifacts({
|
|
932
|
+
workspaceDir: extraAllowedPaths[0] || join(homedir(), '.skimpyclaw'),
|
|
933
|
+
extraAllowedPaths,
|
|
682
934
|
telegramId,
|
|
683
935
|
telegramToken,
|
|
684
936
|
discordToken: useDiscord ? discordToken : undefined,
|
|
@@ -688,14 +940,26 @@ export async function runSetup(options = {}) {
|
|
|
688
940
|
selectedProviders,
|
|
689
941
|
providerSecrets,
|
|
690
942
|
features,
|
|
943
|
+
starters,
|
|
691
944
|
});
|
|
692
945
|
// On reconfigure, preserve dashboard token, cron jobs, subagents, security, langfuse
|
|
693
946
|
if (isReconfigure && existing.config) {
|
|
694
947
|
if (existing.config.dashboard?.token) {
|
|
695
948
|
generatedConfig.dashboard = existing.config.dashboard;
|
|
696
949
|
}
|
|
697
|
-
if (Array.isArray(existing.config.cron?.jobs)
|
|
698
|
-
|
|
950
|
+
if (Array.isArray(existing.config.cron?.jobs)) {
|
|
951
|
+
const existingCronJobs = existing.config.cron.jobs;
|
|
952
|
+
const starterCronJobs = (generatedConfig.cron?.jobs || []);
|
|
953
|
+
const mergedCronJobs = [...existingCronJobs];
|
|
954
|
+
for (const starter of starterCronJobs) {
|
|
955
|
+
const id = String(starter.id || '');
|
|
956
|
+
if (!id)
|
|
957
|
+
continue;
|
|
958
|
+
if (!mergedCronJobs.some((job) => String(job.id) === id)) {
|
|
959
|
+
mergedCronJobs.push(starter);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
generatedConfig.cron = { ...(existing.config.cron || {}), jobs: mergedCronJobs };
|
|
699
963
|
}
|
|
700
964
|
if (existing.config.subagents) {
|
|
701
965
|
generatedConfig.subagents = existing.config.subagents;
|
|
@@ -706,12 +970,35 @@ export async function runSetup(options = {}) {
|
|
|
706
970
|
if (existing.config.langfuse) {
|
|
707
971
|
generatedConfig.langfuse = existing.config.langfuse;
|
|
708
972
|
}
|
|
973
|
+
if (existing.config.sandbox) {
|
|
974
|
+
generatedConfig.sandbox = existing.config.sandbox;
|
|
975
|
+
}
|
|
709
976
|
// Preserve voice provider config if voice was already configured
|
|
710
977
|
if (existing.config.voice?.providers && Object.keys(existing.config.voice.providers).length > 0) {
|
|
711
978
|
generatedConfig.voice = existing.config.voice;
|
|
712
979
|
}
|
|
980
|
+
if (existing.config.skills) {
|
|
981
|
+
const generatedSkills = (generatedConfig.skills || {});
|
|
982
|
+
generatedConfig.skills = {
|
|
983
|
+
...existing.config.skills,
|
|
984
|
+
...generatedSkills,
|
|
985
|
+
entries: {
|
|
986
|
+
...(existing.config.skills.entries || {}),
|
|
987
|
+
...(generatedSkills.entries || {}),
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (enableSandbox && generatedConfig.sandbox) {
|
|
993
|
+
const runtime = detectSandboxRuntime(detectedSandboxRuntime) || detectSandboxRuntime();
|
|
994
|
+
if (runtime) {
|
|
995
|
+
generatedConfig.sandbox.runtime = runtime;
|
|
996
|
+
generatedConfig.sandbox.network = defaultSandboxNetwork(runtime);
|
|
997
|
+
}
|
|
998
|
+
if (!generatedConfig.sandbox.image) {
|
|
999
|
+
generatedConfig.sandbox.image = 'skimpyclaw-sandbox:latest';
|
|
1000
|
+
}
|
|
713
1001
|
}
|
|
714
|
-
const configJson = JSON.stringify(generatedConfig, null, 2);
|
|
715
1002
|
// Create directories
|
|
716
1003
|
console.log('Creating directories...');
|
|
717
1004
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -721,6 +1008,7 @@ export async function runSetup(options = {}) {
|
|
|
721
1008
|
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
722
1009
|
mkdirSync(join(AGENTS_DIR, 'memory'), { recursive: true });
|
|
723
1010
|
const configPath = join(CONFIG_DIR, 'config.json');
|
|
1011
|
+
const configJson = JSON.stringify(generatedConfig, null, 2);
|
|
724
1012
|
writeFileSync(configPath, configJson);
|
|
725
1013
|
console.log(`✓ Config written to ${configPath}`);
|
|
726
1014
|
// Copy templates
|
|
@@ -742,6 +1030,10 @@ export async function runSetup(options = {}) {
|
|
|
742
1030
|
if (createdFallbackTemplates.length > 0) {
|
|
743
1031
|
console.log(`✓ Added missing core templates: ${createdFallbackTemplates.join(', ')}`);
|
|
744
1032
|
}
|
|
1033
|
+
const createdSkills = ensureStarterSkills(starters);
|
|
1034
|
+
if (createdSkills.length > 0) {
|
|
1035
|
+
console.log(`✓ Starter skills created: ${createdSkills.join(', ')}`);
|
|
1036
|
+
}
|
|
745
1037
|
// Merge secrets into .env (preserve existing keys not in new content)
|
|
746
1038
|
const envPath = join(CONFIG_DIR, '.env');
|
|
747
1039
|
if (isReconfigure && existsSync(envPath)) {
|
|
@@ -765,6 +1057,27 @@ export async function runSetup(options = {}) {
|
|
|
765
1057
|
writeFileSync(envPath, envContent);
|
|
766
1058
|
console.log(`✓ Secrets written to ${envPath}`);
|
|
767
1059
|
}
|
|
1060
|
+
if (enableSandbox) {
|
|
1061
|
+
sectionHeader('Sandbox Bootstrap');
|
|
1062
|
+
const sandboxCfg = generatedConfig.sandbox || {};
|
|
1063
|
+
const runtime = detectSandboxRuntime(sandboxCfg.runtime);
|
|
1064
|
+
const image = String(sandboxCfg.image || 'skimpyclaw-sandbox:latest');
|
|
1065
|
+
const network = String(sandboxCfg.network || (runtime ? defaultSandboxNetwork(runtime) : 'bridge'));
|
|
1066
|
+
if (!runtime) {
|
|
1067
|
+
statusWarn('Sandbox enabled, but no runtime detected. Run `skimpyclaw sandbox init` later.');
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
console.log(` Building sandbox image (${runtime}, network=${network})...`);
|
|
1071
|
+
const bootstrap = bootstrapSandbox(runtime, image, network);
|
|
1072
|
+
if (bootstrap.ok) {
|
|
1073
|
+
statusOk(`sandbox ready (${bootstrap.message})`);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
statusWarn(bootstrap.message);
|
|
1077
|
+
console.log(` ${c.dim('You can retry later with: skimpyclaw sandbox init')}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
768
1081
|
// Update USER.md with name
|
|
769
1082
|
writeFileSync(join(AGENTS_DIR, 'USER.md'), `# USER.md - About ${userName}\n\nName: ${userName}\n\n## Preferences\n\n- Direct communication, no fluff\n\n## Routines\n\n- Morning: Review tasks and messages\n- EOD: Review completed work, plan tomorrow\n`);
|
|
770
1083
|
// Create launchd plist from template
|
package/dist/skills.d.ts
CHANGED
|
@@ -27,6 +27,5 @@ export declare function getSkillsForContext(skills: LoadedSkill[], context?: {
|
|
|
27
27
|
}): LoadedSkill[];
|
|
28
28
|
/**
|
|
29
29
|
* Format eligible, context-filtered skills into a markdown prompt section.
|
|
30
|
-
* Respects maxPromptTokens budget (approximate).
|
|
31
30
|
*/
|
|
32
|
-
export declare function formatSkillsPrompt(skills: LoadedSkill[],
|
|
31
|
+
export declare function formatSkillsPrompt(skills: LoadedSkill[], _maxTokens?: number): string;
|
package/dist/skills.js
CHANGED
|
@@ -7,9 +7,6 @@ import matter from 'gray-matter';
|
|
|
7
7
|
import { TTLCache } from './cache.js';
|
|
8
8
|
const DEFAULT_SKILLS_DIR = join(homedir(), '.skimpyclaw', 'skills');
|
|
9
9
|
const DEFAULT_PRIORITY = 100;
|
|
10
|
-
const DEFAULT_MAX_PROMPT_TOKENS = 4000;
|
|
11
|
-
// Rough chars-per-token estimate for prompt budgeting
|
|
12
|
-
const CHARS_PER_TOKEN = 4;
|
|
13
10
|
/**
|
|
14
11
|
* Check if a binary exists on PATH.
|
|
15
12
|
* Returns true if found, false otherwise.
|
|
@@ -233,26 +230,17 @@ export function getSkillsForContext(skills, context) {
|
|
|
233
230
|
}
|
|
234
231
|
/**
|
|
235
232
|
* Format eligible, context-filtered skills into a markdown prompt section.
|
|
236
|
-
* Respects maxPromptTokens budget (approximate).
|
|
237
233
|
*/
|
|
238
|
-
export function formatSkillsPrompt(skills,
|
|
234
|
+
export function formatSkillsPrompt(skills, _maxTokens) {
|
|
239
235
|
if (skills.length === 0)
|
|
240
236
|
return '';
|
|
241
|
-
const budget = (maxTokens ?? DEFAULT_MAX_PROMPT_TOKENS) * CHARS_PER_TOKEN;
|
|
242
237
|
const sections = [];
|
|
243
|
-
let totalChars = 0;
|
|
244
238
|
// Header
|
|
245
239
|
const header = '## Active Skills\n';
|
|
246
|
-
totalChars += header.length;
|
|
247
240
|
for (const skill of skills) {
|
|
248
241
|
const emoji = skill.frontmatter.emoji ? `${skill.frontmatter.emoji} ` : '';
|
|
249
242
|
const section = `### ${emoji}${skill.name}\n\n${skill.body}`;
|
|
250
|
-
if (totalChars + section.length > budget) {
|
|
251
|
-
console.warn(`[skills] Token budget exceeded, skipping remaining skills (included ${sections.length}/${skills.length})`);
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
243
|
sections.push(section);
|
|
255
|
-
totalChars += section.length;
|
|
256
244
|
}
|
|
257
245
|
if (sections.length === 0)
|
|
258
246
|
return '';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if a shell token looks like a file/directory path.
|
|
3
|
+
* Matches: /foo, ./foo, ../foo, ~/foo
|
|
4
|
+
* Does NOT match: flags (-f, --file), bare words (foo), URLs (https://...)
|
|
5
|
+
*/
|
|
6
|
+
export declare function isPathLikeToken(token: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* For an interpreter command segment, extract the script file path if present.
|
|
9
|
+
* Returns null if the command uses inline execution (-c, -e) or reads from stdin.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractScriptTarget(segment: string[]): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Extract all file-path-like tokens from a shell command.
|
|
14
|
+
* Handles piped/chained commands. Resolves ~ and relative paths using cwd.
|
|
15
|
+
* Also extracts script targets from interpreter commands.
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractPathsFromCommand(command: string, cwd?: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Validate that all file paths in a bash command are within allowedPaths.
|
|
20
|
+
* Returns null if all paths are valid, or an error message string if any are outside.
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateBashPaths(command: string, cwd: string | undefined, allowedPaths: string[]): string | null;
|