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.
Files changed (61) hide show
  1. package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
  2. package/dist/__tests__/bash-path-validation.test.js +164 -0
  3. package/dist/__tests__/cron.test.js +51 -1
  4. package/dist/__tests__/doctor.runner.test.js +5 -1
  5. package/dist/__tests__/heartbeat.test.js +5 -5
  6. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  7. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  8. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  9. package/dist/__tests__/sandbox-manager.test.js +119 -0
  10. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  11. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  12. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  13. package/dist/__tests__/sandbox-runtime.test.js +176 -0
  14. package/dist/__tests__/setup.test.js +28 -2
  15. package/dist/__tests__/skills.test.js +2 -11
  16. package/dist/__tests__/tools.test.js +6 -1
  17. package/dist/agent.js +3 -0
  18. package/dist/api.js +5 -1
  19. package/dist/channels/telegram/utils.js +2 -2
  20. package/dist/cli.js +212 -0
  21. package/dist/code-agents/executor.js +17 -4
  22. package/dist/code-agents/types.d.ts +5 -0
  23. package/dist/cron.d.ts +6 -0
  24. package/dist/cron.js +59 -3
  25. package/dist/discord.js +2 -2
  26. package/dist/doctor/checks.d.ts +1 -0
  27. package/dist/doctor/checks.js +47 -0
  28. package/dist/doctor/runner.js +2 -1
  29. package/dist/exec-approval.d.ts +4 -0
  30. package/dist/exec-approval.js +4 -4
  31. package/dist/gateway.js +33 -2
  32. package/dist/heartbeat.js +3 -0
  33. package/dist/providers/anthropic.js +1 -1
  34. package/dist/providers/codex.js +1 -1
  35. package/dist/providers/openai.js +2 -2
  36. package/dist/sandbox/bridge.d.ts +5 -0
  37. package/dist/sandbox/bridge.js +63 -0
  38. package/dist/sandbox/index.d.ts +5 -0
  39. package/dist/sandbox/index.js +4 -0
  40. package/dist/sandbox/manager.d.ts +7 -0
  41. package/dist/sandbox/manager.js +89 -0
  42. package/dist/sandbox/mount-security.d.ts +12 -0
  43. package/dist/sandbox/mount-security.js +118 -0
  44. package/dist/sandbox/runtime.d.ts +38 -0
  45. package/dist/sandbox/runtime.js +187 -0
  46. package/dist/service.js +25 -0
  47. package/dist/setup.d.ts +11 -0
  48. package/dist/setup.js +335 -13
  49. package/dist/skills.d.ts +1 -2
  50. package/dist/skills.js +1 -13
  51. package/dist/tools/bash-path-validation.d.ts +22 -0
  52. package/dist/tools/bash-path-validation.js +130 -0
  53. package/dist/tools/bash-tool.js +23 -1
  54. package/dist/tools/definitions.d.ts +0 -7
  55. package/dist/tools/definitions.js +0 -5
  56. package/dist/tools/execute-context.d.ts +6 -0
  57. package/dist/tools/path-utils.js +16 -2
  58. package/dist/tools.js +84 -2
  59. package/dist/types.d.ts +10 -0
  60. package/dist/voice.js +9 -2
  61. 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: ['${HOME}/.skimpyclaw'],
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: ['${HOME}/.skimpyclaw'],
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: ['${HOME}/.skimpyclaw'],
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 rendering with current environment and install root.
530
- renderGatewayPlist();
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. Optional Features
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 { configJson: rawConfigJson, envContent, config: generatedConfig } = buildSetupArtifacts({
672
- workspaceDir: process.cwd(),
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) && existing.config.cron.jobs.length > 0) {
689
- generatedConfig.cron = existing.config.cron;
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[], maxTokens?: number): string;
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, maxTokens) {
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
+ }