skimpyclaw 0.1.9 → 0.3.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__/cron.test.js +51 -1
- package/dist/__tests__/doctor.runner.test.js +5 -1
- package/dist/__tests__/heartbeat.test.js +5 -5
- 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 +176 -0
- package/dist/__tests__/setup.test.js +28 -2
- package/dist/__tests__/skills.test.js +2 -11
- package/dist/__tests__/tools.test.js +6 -1
- package/dist/agent.js +3 -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.d.ts +6 -0
- package/dist/cron.js +59 -3
- 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 +3 -0
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/codex.js +1 -1
- package/dist/providers/openai.js +2 -2
- 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 +38 -0
- package/dist/sandbox/runtime.js +187 -0
- package/dist/service.js +25 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +335 -13
- 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 +6 -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 +9 -2
- 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: 'tech-digest',
|
|
230
|
+
name: 'Tech News',
|
|
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: 'weather',
|
|
245
|
+
name: 'Weather',
|
|
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,
|
|
@@ -377,25 +487,25 @@ export function buildSetupConfig(input) {
|
|
|
377
487
|
token: '${TELEGRAM_BOT_TOKEN}',
|
|
378
488
|
allowFrom: [parseInt(input.telegramId, 10) || input.telegramId],
|
|
379
489
|
dailyNotesDir: '${HOME}/.skimpyclaw/Daily Notes',
|
|
380
|
-
defaultAllowedPaths:
|
|
490
|
+
defaultAllowedPaths: allPaths,
|
|
381
491
|
},
|
|
382
492
|
discord: {
|
|
383
493
|
enabled: useDiscord,
|
|
384
494
|
token: useDiscord ? '${DISCORD_BOT_TOKEN}' : '',
|
|
385
495
|
allowFrom: useDiscord ? [input.discordUserId || ''] : [],
|
|
386
|
-
defaultAllowedPaths:
|
|
496
|
+
defaultAllowedPaths: allPaths,
|
|
387
497
|
...(input.discordDefaultChannelId ? { defaultChannelId: input.discordDefaultChannelId } : {}),
|
|
388
498
|
},
|
|
389
499
|
},
|
|
390
500
|
cron: {
|
|
391
|
-
jobs:
|
|
501
|
+
jobs: starterCronJobs,
|
|
392
502
|
},
|
|
393
503
|
heartbeat: {
|
|
394
504
|
intervalMs: 1800000,
|
|
395
505
|
prompt: 'Read ~/.skimpyclaw/agents/main/HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
|
|
396
506
|
tools: {
|
|
397
507
|
enabled: true,
|
|
398
|
-
allowedPaths:
|
|
508
|
+
allowedPaths: allPaths,
|
|
399
509
|
maxIterations: 10,
|
|
400
510
|
bashTimeout: 15000,
|
|
401
511
|
...(features.browser ? { browser: { enabled: true } } : { browser: { enabled: false } }),
|
|
@@ -414,6 +524,22 @@ export function buildSetupConfig(input) {
|
|
|
414
524
|
},
|
|
415
525
|
},
|
|
416
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
|
+
} : {}),
|
|
417
543
|
dashboard: {
|
|
418
544
|
token: randomUUID(),
|
|
419
545
|
},
|
|
@@ -433,6 +559,34 @@ const REQUIRED_TEMPLATE_DEFAULTS = {
|
|
|
433
559
|
'USER.md': '# USER\n\nName: User\n',
|
|
434
560
|
'HEARTBEAT.md': '# HEARTBEAT\n\nIf nothing needs attention, reply HEARTBEAT_OK.\n',
|
|
435
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
|
+
};
|
|
436
590
|
function ensureCoreTemplates(agentDir) {
|
|
437
591
|
const created = [];
|
|
438
592
|
for (const [file, content] of Object.entries(REQUIRED_TEMPLATE_DEFAULTS)) {
|
|
@@ -444,6 +598,26 @@ function ensureCoreTemplates(agentDir) {
|
|
|
444
598
|
}
|
|
445
599
|
return created;
|
|
446
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
|
+
}
|
|
447
621
|
async function quickFetch(url, init) {
|
|
448
622
|
return await fetch(url, { ...init, signal: AbortSignal.timeout(12000) });
|
|
449
623
|
}
|
|
@@ -526,8 +700,11 @@ export async function runSetup(options = {}) {
|
|
|
526
700
|
if (templates.length === 0) {
|
|
527
701
|
throw new Error(`No markdown templates found in ${TEMPLATES_DIR}`);
|
|
528
702
|
}
|
|
529
|
-
// Validate launchd template
|
|
530
|
-
|
|
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
|
+
}
|
|
531
708
|
console.log('✅ Onboarding dry run successful.');
|
|
532
709
|
console.log(`Would create config under: ${CONFIG_DIR}`);
|
|
533
710
|
console.log(`Would copy ${templates.length} templates to: ${AGENTS_DIR}`);
|
|
@@ -612,7 +789,42 @@ export async function runSetup(options = {}) {
|
|
|
612
789
|
sectionHeader('5. Your Name');
|
|
613
790
|
const userName = (await ask(rl, ' What should I call you? ')) || 'User';
|
|
614
791
|
statusOk(userName);
|
|
615
|
-
// 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
|
|
616
828
|
const existingBrowser = existing.config?.heartbeat?.tools?.browser?.enabled === true
|
|
617
829
|
|| existing.config?.channels?.telegram?.tools?.browser?.enabled === true;
|
|
618
830
|
const existingVoice = existing.config?.voice?.enabled === true;
|
|
@@ -663,13 +875,62 @@ export async function runSetup(options = {}) {
|
|
|
663
875
|
else {
|
|
664
876
|
statusOk('MCP tools disabled');
|
|
665
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
|
+
}
|
|
666
901
|
const features = {
|
|
667
902
|
browser: enableBrowser,
|
|
668
903
|
voice: enableVoice,
|
|
669
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,
|
|
670
930
|
};
|
|
671
|
-
const {
|
|
672
|
-
workspaceDir:
|
|
931
|
+
const { envContent, config: generatedConfig } = buildSetupArtifacts({
|
|
932
|
+
workspaceDir: extraAllowedPaths[0] || join(homedir(), '.skimpyclaw'),
|
|
933
|
+
extraAllowedPaths,
|
|
673
934
|
telegramId,
|
|
674
935
|
telegramToken,
|
|
675
936
|
discordToken: useDiscord ? discordToken : undefined,
|
|
@@ -679,14 +940,26 @@ export async function runSetup(options = {}) {
|
|
|
679
940
|
selectedProviders,
|
|
680
941
|
providerSecrets,
|
|
681
942
|
features,
|
|
943
|
+
starters,
|
|
682
944
|
});
|
|
683
945
|
// On reconfigure, preserve dashboard token, cron jobs, subagents, security, langfuse
|
|
684
946
|
if (isReconfigure && existing.config) {
|
|
685
947
|
if (existing.config.dashboard?.token) {
|
|
686
948
|
generatedConfig.dashboard = existing.config.dashboard;
|
|
687
949
|
}
|
|
688
|
-
if (Array.isArray(existing.config.cron?.jobs)
|
|
689
|
-
|
|
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 };
|
|
690
963
|
}
|
|
691
964
|
if (existing.config.subagents) {
|
|
692
965
|
generatedConfig.subagents = existing.config.subagents;
|
|
@@ -697,12 +970,35 @@ export async function runSetup(options = {}) {
|
|
|
697
970
|
if (existing.config.langfuse) {
|
|
698
971
|
generatedConfig.langfuse = existing.config.langfuse;
|
|
699
972
|
}
|
|
973
|
+
if (existing.config.sandbox) {
|
|
974
|
+
generatedConfig.sandbox = existing.config.sandbox;
|
|
975
|
+
}
|
|
700
976
|
// Preserve voice provider config if voice was already configured
|
|
701
977
|
if (existing.config.voice?.providers && Object.keys(existing.config.voice.providers).length > 0) {
|
|
702
978
|
generatedConfig.voice = existing.config.voice;
|
|
703
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
|
+
}
|
|
704
1001
|
}
|
|
705
|
-
const configJson = JSON.stringify(generatedConfig, null, 2);
|
|
706
1002
|
// Create directories
|
|
707
1003
|
console.log('Creating directories...');
|
|
708
1004
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -712,6 +1008,7 @@ export async function runSetup(options = {}) {
|
|
|
712
1008
|
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
713
1009
|
mkdirSync(join(AGENTS_DIR, 'memory'), { recursive: true });
|
|
714
1010
|
const configPath = join(CONFIG_DIR, 'config.json');
|
|
1011
|
+
const configJson = JSON.stringify(generatedConfig, null, 2);
|
|
715
1012
|
writeFileSync(configPath, configJson);
|
|
716
1013
|
console.log(`✓ Config written to ${configPath}`);
|
|
717
1014
|
// Copy templates
|
|
@@ -733,6 +1030,10 @@ export async function runSetup(options = {}) {
|
|
|
733
1030
|
if (createdFallbackTemplates.length > 0) {
|
|
734
1031
|
console.log(`✓ Added missing core templates: ${createdFallbackTemplates.join(', ')}`);
|
|
735
1032
|
}
|
|
1033
|
+
const createdSkills = ensureStarterSkills(starters);
|
|
1034
|
+
if (createdSkills.length > 0) {
|
|
1035
|
+
console.log(`✓ Starter skills created: ${createdSkills.join(', ')}`);
|
|
1036
|
+
}
|
|
736
1037
|
// Merge secrets into .env (preserve existing keys not in new content)
|
|
737
1038
|
const envPath = join(CONFIG_DIR, '.env');
|
|
738
1039
|
if (isReconfigure && existsSync(envPath)) {
|
|
@@ -756,6 +1057,27 @@ export async function runSetup(options = {}) {
|
|
|
756
1057
|
writeFileSync(envPath, envContent);
|
|
757
1058
|
console.log(`✓ Secrets written to ${envPath}`);
|
|
758
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
|
+
}
|
|
759
1081
|
// Update USER.md with name
|
|
760
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`);
|
|
761
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;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Bash argument path validation — extracts file paths from command tokens
|
|
2
|
+
// and validates them against the allowedPaths list.
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { getCommandSegments, getSegmentCommandIndex, getExecutableName, } from '../exec-approval.js';
|
|
6
|
+
import { isPathAllowed } from './path-utils.js';
|
|
7
|
+
// --- Path-like token detection ---
|
|
8
|
+
/**
|
|
9
|
+
* Returns true if a shell token looks like a file/directory path.
|
|
10
|
+
* Matches: /foo, ./foo, ../foo, ~/foo
|
|
11
|
+
* Does NOT match: flags (-f, --file), bare words (foo), URLs (https://...)
|
|
12
|
+
*/
|
|
13
|
+
export function isPathLikeToken(token) {
|
|
14
|
+
const t = token.trim();
|
|
15
|
+
if (!t || t === '-' || t === '--')
|
|
16
|
+
return false;
|
|
17
|
+
// Absolute path
|
|
18
|
+
if (t.startsWith('/'))
|
|
19
|
+
return true;
|
|
20
|
+
// Relative paths
|
|
21
|
+
if (t.startsWith('./') || t.startsWith('../') || t === '.' || t === '..')
|
|
22
|
+
return true;
|
|
23
|
+
// Home-relative path
|
|
24
|
+
if (t.startsWith('~/') || t === '~')
|
|
25
|
+
return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
// --- Interpreter script target extraction ---
|
|
29
|
+
const INTERPRETERS = new Set([
|
|
30
|
+
'python', 'python3', 'python3.11', 'python3.12', 'python3.13',
|
|
31
|
+
'node', 'deno', 'bun',
|
|
32
|
+
'perl', 'ruby', 'php', 'lua',
|
|
33
|
+
'bash', 'sh', 'zsh', 'fish',
|
|
34
|
+
]);
|
|
35
|
+
/** Flags that consume the next argument (so it's not a script path). */
|
|
36
|
+
const INTERPRETER_FLAGS_WITH_VALUE = new Set([
|
|
37
|
+
'-c', '-e', '-m', '-W', '-X', '-O',
|
|
38
|
+
'--eval', '--execute', '--require',
|
|
39
|
+
]);
|
|
40
|
+
/** Flags that mean "read from stdin" or "inline code follows" — stop looking for script path. */
|
|
41
|
+
const INTERPRETER_INLINE_FLAGS = new Set([
|
|
42
|
+
'-c', '-e', '--eval', '--execute', '-r', '-Command',
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* For an interpreter command segment, extract the script file path if present.
|
|
46
|
+
* Returns null if the command uses inline execution (-c, -e) or reads from stdin.
|
|
47
|
+
*/
|
|
48
|
+
export function extractScriptTarget(segment) {
|
|
49
|
+
const cmdIdx = getSegmentCommandIndex(segment);
|
|
50
|
+
if (cmdIdx >= segment.length)
|
|
51
|
+
return null;
|
|
52
|
+
const cmd = getExecutableName(segment[cmdIdx]);
|
|
53
|
+
if (!INTERPRETERS.has(cmd))
|
|
54
|
+
return null;
|
|
55
|
+
const args = segment.slice(cmdIdx + 1);
|
|
56
|
+
for (let i = 0; i < args.length; i++) {
|
|
57
|
+
const arg = args[i];
|
|
58
|
+
// Inline execution — no script file to validate
|
|
59
|
+
if (INTERPRETER_INLINE_FLAGS.has(arg))
|
|
60
|
+
return null;
|
|
61
|
+
// Flag that consumes next arg — skip both
|
|
62
|
+
if (INTERPRETER_FLAGS_WITH_VALUE.has(arg)) {
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Skip other flags (single or double dash)
|
|
67
|
+
if (arg.startsWith('-'))
|
|
68
|
+
continue;
|
|
69
|
+
// First non-flag argument is the script path
|
|
70
|
+
return arg;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
// --- Path extraction from full command ---
|
|
75
|
+
/**
|
|
76
|
+
* Extract all file-path-like tokens from a shell command.
|
|
77
|
+
* Handles piped/chained commands. Resolves ~ and relative paths using cwd.
|
|
78
|
+
* Also extracts script targets from interpreter commands.
|
|
79
|
+
*/
|
|
80
|
+
export function extractPathsFromCommand(command, cwd) {
|
|
81
|
+
const segments = getCommandSegments(command);
|
|
82
|
+
const paths = [];
|
|
83
|
+
const baseCwd = cwd || process.cwd();
|
|
84
|
+
for (const segment of segments) {
|
|
85
|
+
const cmdIdx = getSegmentCommandIndex(segment);
|
|
86
|
+
// Check for interpreter script target
|
|
87
|
+
const scriptTarget = extractScriptTarget(segment);
|
|
88
|
+
if (scriptTarget) {
|
|
89
|
+
paths.push(resolvePath(scriptTarget, baseCwd));
|
|
90
|
+
}
|
|
91
|
+
// Check all non-command tokens for path-like values
|
|
92
|
+
for (let i = cmdIdx + 1; i < segment.length; i++) {
|
|
93
|
+
const token = segment[i];
|
|
94
|
+
if (isPathLikeToken(token)) {
|
|
95
|
+
paths.push(resolvePath(token, baseCwd));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Deduplicate
|
|
100
|
+
return [...new Set(paths)];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a token to an absolute path, expanding ~ and relative paths.
|
|
104
|
+
*/
|
|
105
|
+
function resolvePath(token, cwd) {
|
|
106
|
+
if (token.startsWith('~/') || token === '~') {
|
|
107
|
+
return resolve(homedir(), token.slice(2) || '.');
|
|
108
|
+
}
|
|
109
|
+
return resolve(cwd, token);
|
|
110
|
+
}
|
|
111
|
+
// --- Validation ---
|
|
112
|
+
/**
|
|
113
|
+
* Validate that all file paths in a bash command are within allowedPaths.
|
|
114
|
+
* Returns null if all paths are valid, or an error message string if any are outside.
|
|
115
|
+
*/
|
|
116
|
+
export function validateBashPaths(command, cwd, allowedPaths) {
|
|
117
|
+
// If no allowedPaths configured, skip validation (permissive mode)
|
|
118
|
+
if (!allowedPaths || allowedPaths.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
const paths = extractPathsFromCommand(command, cwd);
|
|
121
|
+
const blocked = [];
|
|
122
|
+
for (const p of paths) {
|
|
123
|
+
if (!isPathAllowed(p, allowedPaths)) {
|
|
124
|
+
blocked.push(p);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (blocked.length === 0)
|
|
128
|
+
return null;
|
|
129
|
+
return `Error: Command references paths outside allowed directories: ${blocked.join(', ')}`;
|
|
130
|
+
}
|