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.
Files changed (58) 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__/doctor.runner.test.js +5 -1
  4. package/dist/__tests__/heartbeat.test.js +30 -3
  5. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  6. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  7. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  8. package/dist/__tests__/sandbox-manager.test.js +119 -0
  9. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  10. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  11. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  12. package/dist/__tests__/sandbox-runtime.test.js +140 -0
  13. package/dist/__tests__/setup.test.js +32 -3
  14. package/dist/__tests__/skills.test.js +2 -11
  15. package/dist/__tests__/tools.test.js +6 -1
  16. package/dist/__tests__/voice.test.js +12 -0
  17. package/dist/agent.js +2 -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.js +16 -2
  24. package/dist/discord.js +2 -2
  25. package/dist/doctor/checks.d.ts +1 -0
  26. package/dist/doctor/checks.js +47 -0
  27. package/dist/doctor/runner.js +2 -1
  28. package/dist/exec-approval.d.ts +4 -0
  29. package/dist/exec-approval.js +4 -4
  30. package/dist/gateway.js +33 -2
  31. package/dist/heartbeat.js +7 -3
  32. package/dist/providers/openai.js +1 -1
  33. package/dist/sandbox/bridge.d.ts +5 -0
  34. package/dist/sandbox/bridge.js +63 -0
  35. package/dist/sandbox/index.d.ts +5 -0
  36. package/dist/sandbox/index.js +4 -0
  37. package/dist/sandbox/manager.d.ts +7 -0
  38. package/dist/sandbox/manager.js +89 -0
  39. package/dist/sandbox/mount-security.d.ts +12 -0
  40. package/dist/sandbox/mount-security.js +118 -0
  41. package/dist/sandbox/runtime.d.ts +33 -0
  42. package/dist/sandbox/runtime.js +167 -0
  43. package/dist/service.js +17 -0
  44. package/dist/setup.d.ts +11 -0
  45. package/dist/setup.js +336 -23
  46. package/dist/skills.d.ts +1 -2
  47. package/dist/skills.js +1 -13
  48. package/dist/tools/bash-path-validation.d.ts +22 -0
  49. package/dist/tools/bash-path-validation.js +130 -0
  50. package/dist/tools/bash-tool.js +23 -1
  51. package/dist/tools/definitions.d.ts +0 -7
  52. package/dist/tools/definitions.js +0 -5
  53. package/dist/tools/execute-context.d.ts +4 -0
  54. package/dist/tools/path-utils.js +16 -2
  55. package/dist/tools.js +84 -2
  56. package/dist/types.d.ts +10 -0
  57. package/dist/voice.js +5 -1
  58. 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 rendering with current environment and install root.
539
- 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
+ }
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. 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
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 { configJson: rawConfigJson, envContent, config: generatedConfig } = buildSetupArtifacts({
681
- workspaceDir: process.cwd(),
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) && existing.config.cron.jobs.length > 0) {
698
- 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 };
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[], 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;