skimpyclaw 0.3.4 → 0.3.6

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.
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
2
+ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckPlaywrightIfBrowserEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
3
3
  mockLoadConfig: vi.fn(),
4
4
  mockCheckNodeVersion: vi.fn(),
5
5
  mockCheckPackageManagerAvailable: vi.fn(),
@@ -12,6 +12,7 @@ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable,
12
12
  mockCheckTelegramToken: vi.fn(),
13
13
  mockCheckDiscordToken: vi.fn(),
14
14
  mockCheckBrowserBinaryIfEnabled: vi.fn(),
15
+ mockCheckPlaywrightIfBrowserEnabled: vi.fn(),
15
16
  mockCheckVoiceDependencies: vi.fn(),
16
17
  mockCheckMcpConfig: vi.fn(),
17
18
  mockCheckGatewayHostBindable: vi.fn(),
@@ -34,6 +35,7 @@ vi.mock('../doctor/checks.js', () => ({
34
35
  checkTelegramToken: mockCheckTelegramToken,
35
36
  checkDiscordToken: mockCheckDiscordToken,
36
37
  checkBrowserBinaryIfEnabled: mockCheckBrowserBinaryIfEnabled,
38
+ checkPlaywrightIfBrowserEnabled: mockCheckPlaywrightIfBrowserEnabled,
37
39
  checkVoiceDependencies: mockCheckVoiceDependencies,
38
40
  checkMcpConfig: mockCheckMcpConfig,
39
41
  checkGatewayHostBindable: mockCheckGatewayHostBindable,
@@ -75,6 +77,7 @@ describe('doctor runner', () => {
75
77
  mockCheckTelegramToken.mockReset();
76
78
  mockCheckDiscordToken.mockReset();
77
79
  mockCheckBrowserBinaryIfEnabled.mockReset();
80
+ mockCheckPlaywrightIfBrowserEnabled.mockReset();
78
81
  mockCheckVoiceDependencies.mockReset();
79
82
  mockCheckMcpConfig.mockReset();
80
83
  mockCheckGatewayHostBindable.mockReset();
@@ -92,6 +95,7 @@ describe('doctor runner', () => {
92
95
  mockCheckTelegramToken.mockResolvedValue(okCheck('telegram_token_valid', 'channels'));
93
96
  mockCheckDiscordToken.mockResolvedValue(okCheck('discord_token_valid', 'channels'));
94
97
  mockCheckBrowserBinaryIfEnabled.mockResolvedValue(okCheck('browser_binary_available', 'runtime'));
98
+ mockCheckPlaywrightIfBrowserEnabled.mockResolvedValue(okCheck('playwright_installed', 'runtime', 'Browser tools disabled'));
95
99
  mockCheckVoiceDependencies.mockResolvedValue(okCheck('voice_dependencies', 'runtime', 'Voice disabled'));
96
100
  mockCheckMcpConfig.mockResolvedValue(okCheck('mcp_config', 'runtime', 'MCP tools not configured'));
97
101
  mockCheckGatewayHostBindable.mockResolvedValue(okCheck('gateway_host_bindable', 'runtime', '127.0.0.1 (always available)'));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2
+ import { join } from 'path';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ // Import the real functions (no mocks needed — these are pure fs reads)
6
+ import { detectPackageManager, buildValidationCommand } from '../code-agents/executor.js';
7
+ describe('detectPackageManager', () => {
8
+ let tempDir;
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), 'pm-detect-'));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(tempDir, { recursive: true, force: true });
14
+ });
15
+ it('returns pnpm as fallback when no indicators present', () => {
16
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
17
+ });
18
+ it('detects yarn from packageManager field', () => {
19
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
20
+ name: 'test',
21
+ packageManager: 'yarn@4.1.0',
22
+ }));
23
+ expect(detectPackageManager(tempDir)).toBe('yarn');
24
+ });
25
+ it('detects pnpm from packageManager field', () => {
26
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
27
+ name: 'test',
28
+ packageManager: 'pnpm@9.0.0',
29
+ }));
30
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
31
+ });
32
+ it('detects npm from packageManager field', () => {
33
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
34
+ name: 'test',
35
+ packageManager: 'npm@10.0.0',
36
+ }));
37
+ expect(detectPackageManager(tempDir)).toBe('npm');
38
+ });
39
+ it('detects bun from packageManager field', () => {
40
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
41
+ name: 'test',
42
+ packageManager: 'bun@1.0.0',
43
+ }));
44
+ expect(detectPackageManager(tempDir)).toBe('bun');
45
+ });
46
+ it('detects yarn from yarn.lock', () => {
47
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
48
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
49
+ expect(detectPackageManager(tempDir)).toBe('yarn');
50
+ });
51
+ it('detects pnpm from pnpm-lock.yaml', () => {
52
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
53
+ writeFileSync(join(tempDir, 'pnpm-lock.yaml'), '');
54
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
55
+ });
56
+ it('detects npm from package-lock.json', () => {
57
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
58
+ writeFileSync(join(tempDir, 'package-lock.json'), '{}');
59
+ expect(detectPackageManager(tempDir)).toBe('npm');
60
+ });
61
+ it('detects bun from bun.lockb', () => {
62
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
63
+ writeFileSync(join(tempDir, 'bun.lockb'), '');
64
+ expect(detectPackageManager(tempDir)).toBe('bun');
65
+ });
66
+ it('detects bun from bun.lock', () => {
67
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
68
+ writeFileSync(join(tempDir, 'bun.lock'), '');
69
+ expect(detectPackageManager(tempDir)).toBe('bun');
70
+ });
71
+ it('packageManager field takes priority over lockfile', () => {
72
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
73
+ name: 'test',
74
+ packageManager: 'yarn@4.1.0',
75
+ }));
76
+ // Also has a pnpm lockfile — packageManager should win
77
+ writeFileSync(join(tempDir, 'pnpm-lock.yaml'), '');
78
+ expect(detectPackageManager(tempDir)).toBe('yarn');
79
+ });
80
+ it('handles malformed package.json gracefully', () => {
81
+ writeFileSync(join(tempDir, 'package.json'), 'not valid json{{{');
82
+ expect(detectPackageManager(tempDir)).toBe('pnpm');
83
+ });
84
+ // wp-calypso case: yarn project with yarn.lock
85
+ it('detects yarn for wp-calypso-like project', () => {
86
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
87
+ name: 'wp-calypso',
88
+ scripts: { build: 'yarn build:client', test: 'jest' },
89
+ }));
90
+ writeFileSync(join(tempDir, 'yarn.lock'), '# yarn lockfile v1\n');
91
+ expect(detectPackageManager(tempDir)).toBe('yarn');
92
+ });
93
+ });
94
+ describe('buildValidationCommand', () => {
95
+ let tempDir;
96
+ beforeEach(() => {
97
+ tempDir = mkdtempSync(join(tmpdir(), 'pm-cmd-'));
98
+ });
99
+ afterEach(() => {
100
+ rmSync(tempDir, { recursive: true, force: true });
101
+ });
102
+ it('returns pnpm build && pnpm test for pnpm project with both scripts', () => {
103
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
104
+ name: 'test',
105
+ scripts: { build: 'tsc', test: 'vitest' },
106
+ }));
107
+ writeFileSync(join(tempDir, 'pnpm-lock.yaml'), '');
108
+ expect(buildValidationCommand(tempDir)).toBe('pnpm build && pnpm test');
109
+ });
110
+ it('returns yarn build && yarn test for yarn project with both scripts', () => {
111
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
112
+ name: 'test',
113
+ scripts: { build: 'tsc', test: 'jest' },
114
+ }));
115
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
116
+ expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
117
+ });
118
+ it('returns npm run build && npm run test for npm project with both scripts', () => {
119
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
120
+ name: 'test',
121
+ scripts: { build: 'tsc', test: 'jest' },
122
+ }));
123
+ writeFileSync(join(tempDir, 'package-lock.json'), '{}');
124
+ expect(buildValidationCommand(tempDir)).toBe('npm run build && npm run test');
125
+ });
126
+ it('returns bun build && bun test for bun project with both scripts', () => {
127
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
128
+ name: 'test',
129
+ scripts: { build: 'bun build', test: 'bun test' },
130
+ }));
131
+ writeFileSync(join(tempDir, 'bun.lockb'), '');
132
+ expect(buildValidationCommand(tempDir)).toBe('bun build && bun test');
133
+ });
134
+ it('returns only test command when build script is missing', () => {
135
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
136
+ name: 'test',
137
+ scripts: { test: 'jest' },
138
+ }));
139
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
140
+ expect(buildValidationCommand(tempDir)).toBe('yarn test');
141
+ });
142
+ it('returns only build command when test script is missing', () => {
143
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
144
+ name: 'test',
145
+ scripts: { build: 'tsc' },
146
+ }));
147
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
148
+ expect(buildValidationCommand(tempDir)).toBe('yarn build');
149
+ });
150
+ it('falls back to both commands when no package.json exists', () => {
151
+ // No package.json, no lockfile → pnpm fallback
152
+ expect(buildValidationCommand(tempDir)).toBe('pnpm build && pnpm test');
153
+ });
154
+ it('falls back to both commands when scripts object is empty', () => {
155
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
156
+ name: 'test',
157
+ scripts: {},
158
+ }));
159
+ writeFileSync(join(tempDir, 'yarn.lock'), '');
160
+ expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
161
+ });
162
+ // wp-calypso scenario
163
+ it('generates yarn commands for wp-calypso-like project', () => {
164
+ writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
165
+ name: 'wp-calypso',
166
+ packageManager: 'yarn@4.5.0',
167
+ scripts: { build: 'yarn workspaces foreach build', test: 'jest' },
168
+ }));
169
+ writeFileSync(join(tempDir, 'yarn.lock'), '# yarn lockfile v1\n');
170
+ expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
171
+ });
172
+ });
@@ -128,8 +128,9 @@ describe('setup config generation', () => {
128
128
  cronWeather: true,
129
129
  timezone: 'America/New_York',
130
130
  weatherLocation: 'Austin, TX',
131
- skillCodeReview: true,
132
131
  skillDailyNotes: true,
132
+ skillWeather: true,
133
+ skillWebSearch: false,
133
134
  },
134
135
  });
135
136
  expect(config.cron.jobs).toHaveLength(2);
@@ -138,7 +139,7 @@ describe('setup config generation', () => {
138
139
  expect(config.cron.jobs[1].schedule.tz).toBe('America/New_York');
139
140
  expect(config.cron.jobs[1].payload.message).toContain('Austin, TX');
140
141
  expect(config.skills.enabled).toBe(true);
141
- expect(config.skills.entries['code-review']).toBe(true);
142
142
  expect(config.skills.entries['daily-notes']).toBe(true);
143
+ expect(config.skills.entries['weather']).toBe(true);
143
144
  });
144
145
  });
package/dist/cli.js CHANGED
@@ -780,10 +780,18 @@ function sandboxImageExists(runtime, image) {
780
780
  return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
781
781
  }
782
782
  function resolveSandboxDir() {
783
+ // 1. Check CWD (user is in repo root)
783
784
  const cwdSandbox = join(process.cwd(), 'sandbox');
784
785
  if (existsSync(join(cwdSandbox, 'Dockerfile'))) {
785
786
  return cwdSandbox;
786
787
  }
788
+ // 2. Check relative to package root (global/npm install)
789
+ const thisFile = fileURLToPath(import.meta.url);
790
+ const pkgRoot = join(thisFile, '..', '..'); // dist/src/cli.js -> repo root
791
+ const pkgSandbox = join(pkgRoot, 'sandbox');
792
+ if (existsSync(join(pkgSandbox, 'Dockerfile'))) {
793
+ return pkgSandbox;
794
+ }
787
795
  return null;
788
796
  }
789
797
  function parseSandboxOption(args, flag) {
@@ -942,7 +950,30 @@ async function commandSandbox(args) {
942
950
  }
943
951
  return failed ? 1 : 0;
944
952
  }
945
- console.log('Usage: skimpyclaw sandbox <status|prune|init|doctor>');
953
+ console.log(`Usage: skimpyclaw sandbox <command>
954
+
955
+ Commands:
956
+ init Build sandbox image and enable in config
957
+ status List active sandbox containers
958
+ prune Remove orphaned sandbox containers
959
+ doctor Run targeted sandbox diagnostics
960
+
961
+ Init options:
962
+ --runtime <container|docker> Container runtime (default: auto-detect)
963
+ --profile <minimal|dev|full> Package set (default: minimal)
964
+ --image <name> Image name (default: skimpyclaw-sandbox:latest)
965
+ --network <name> Network name (default: auto per runtime)
966
+
967
+ Profiles:
968
+ minimal bash, curl, git, gh, jq, python3, ripgrep, pnpm
969
+ dev minimal + gcc, g++, make
970
+ full dev + pip3, sqlite3, unzip, less
971
+
972
+ Which runtime?
973
+ Apple Containers (macOS 26+) — lighter, faster startup, no daemon.
974
+ Docker — cross-platform, use if you already run Docker.
975
+ Auto-detect prefers Apple Containers, falls back to Docker.
976
+ `);
946
977
  return 1;
947
978
  }
948
979
  export async function runCli(argv = process.argv.slice(2)) {
@@ -951,6 +982,17 @@ export async function runCli(argv = process.argv.slice(2)) {
951
982
  printHelp();
952
983
  return 0;
953
984
  }
985
+ if (command === '--version' || command === '-v' || command === 'version') {
986
+ const pkgPath = join(fileURLToPath(import.meta.url), '..', '..', 'package.json');
987
+ try {
988
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
989
+ console.log(`skimpyclaw v${pkg.version}`);
990
+ }
991
+ catch {
992
+ console.log('skimpyclaw (version unknown)');
993
+ }
994
+ return 0;
995
+ }
954
996
  try {
955
997
  if (command === 'start') {
956
998
  if (args.includes('--daemon')) {
@@ -1,4 +1,20 @@
1
1
  import type { CodeAgentBackgroundOptions, ValidationResult } from './types.js';
2
+ /** Supported JS package managers. */
3
+ export type PackageManager = 'pnpm' | 'yarn' | 'npm' | 'bun';
4
+ /**
5
+ * Detect the package manager for a project directory.
6
+ * Detection order (first match wins):
7
+ * 1. package.json `packageManager` field (e.g. "yarn@4.1.0")
8
+ * 2. Lockfile presence: yarn.lock → yarn, pnpm-lock.yaml → pnpm, bun.lockb / bun.lock → bun, package-lock.json → npm
9
+ * 3. Fallback: 'pnpm' (SkimpyClaw default)
10
+ */
11
+ export declare function detectPackageManager(workdir: string): PackageManager;
12
+ /**
13
+ * Build the validation command for a project directory.
14
+ * Checks for `build` and `test` scripts in package.json, then runs them
15
+ * with the detected package manager. Falls back to `<pm> build && <pm> test`.
16
+ */
17
+ export declare function buildValidationCommand(workdir: string): string;
2
18
  /** Run build/test validation. Shared by solo agents and team orchestrator. */
3
19
  export declare function runValidation(workdir: string): Promise<ValidationResult>;
4
20
  /** Background execution of a coding agent. Updates task status throughout. */
@@ -1,6 +1,6 @@
1
1
  // Code Agent Executor - Background execution logic
2
2
  import { spawn, exec } from 'child_process';
3
- import { createWriteStream } from 'fs';
3
+ import { createWriteStream, existsSync, readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  // SKIMPYCLAW_ROOT for log paths
6
6
  const SKIMPYCLAW_ROOT = join(import.meta.dirname || process.cwd(), '..', '..');
@@ -12,10 +12,83 @@ import { startTrace, addEvent, endTrace } from '../audit.js';
12
12
  import { buildUsageRecord, recordUsage } from '../usage.js';
13
13
  import { ensureContainer, SANDBOX_DEFAULTS, getRuntime } from '../sandbox/index.js';
14
14
  const CANCELLED_MESSAGE = 'Cancelled by user';
15
+ /**
16
+ * Detect the package manager for a project directory.
17
+ * Detection order (first match wins):
18
+ * 1. package.json `packageManager` field (e.g. "yarn@4.1.0")
19
+ * 2. Lockfile presence: yarn.lock → yarn, pnpm-lock.yaml → pnpm, bun.lockb / bun.lock → bun, package-lock.json → npm
20
+ * 3. Fallback: 'pnpm' (SkimpyClaw default)
21
+ */
22
+ export function detectPackageManager(workdir) {
23
+ // 1. Check package.json packageManager field
24
+ try {
25
+ const pkgPath = join(workdir, 'package.json');
26
+ if (existsSync(pkgPath)) {
27
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
28
+ if (typeof pkg.packageManager === 'string') {
29
+ const name = pkg.packageManager.split('@')[0].toLowerCase();
30
+ if (name === 'yarn')
31
+ return 'yarn';
32
+ if (name === 'pnpm')
33
+ return 'pnpm';
34
+ if (name === 'npm')
35
+ return 'npm';
36
+ if (name === 'bun')
37
+ return 'bun';
38
+ }
39
+ }
40
+ }
41
+ catch { /* ignore parse errors, fall through */ }
42
+ // 2. Check lockfiles
43
+ if (existsSync(join(workdir, 'yarn.lock')))
44
+ return 'yarn';
45
+ if (existsSync(join(workdir, 'pnpm-lock.yaml')))
46
+ return 'pnpm';
47
+ if (existsSync(join(workdir, 'bun.lockb')) || existsSync(join(workdir, 'bun.lock')))
48
+ return 'bun';
49
+ if (existsSync(join(workdir, 'package-lock.json')))
50
+ return 'npm';
51
+ // 3. Fallback
52
+ return 'pnpm';
53
+ }
54
+ /**
55
+ * Build the validation command for a project directory.
56
+ * Checks for `build` and `test` scripts in package.json, then runs them
57
+ * with the detected package manager. Falls back to `<pm> build && <pm> test`.
58
+ */
59
+ export function buildValidationCommand(workdir) {
60
+ const pm = detectPackageManager(workdir);
61
+ const run = pm === 'npm' ? 'npm run' : pm;
62
+ // Check which scripts exist in package.json
63
+ let hasBuild = false;
64
+ let hasTest = false;
65
+ try {
66
+ const pkgPath = join(workdir, 'package.json');
67
+ if (existsSync(pkgPath)) {
68
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
69
+ const scripts = pkg.scripts || {};
70
+ hasBuild = !!scripts.build;
71
+ hasTest = !!scripts.test;
72
+ }
73
+ }
74
+ catch { /* ignore */ }
75
+ const parts = [];
76
+ if (hasBuild)
77
+ parts.push(`${run} build`);
78
+ if (hasTest)
79
+ parts.push(`${run} test`);
80
+ // If neither build nor test scripts exist, still try — the scripts
81
+ // might be defined in a workspace root or the commands may work anyway
82
+ if (parts.length === 0) {
83
+ parts.push(`${run} build`, `${run} test`);
84
+ }
85
+ return parts.join(' && ');
86
+ }
15
87
  /** Run build/test validation. Shared by solo agents and team orchestrator. */
16
88
  export function runValidation(workdir) {
89
+ const cmd = buildValidationCommand(workdir);
17
90
  return new Promise((resolve) => {
18
- exec('pnpm build && pnpm test', {
91
+ exec(cmd, {
19
92
  cwd: workdir,
20
93
  timeout: VALIDATE_TIMEOUT_MS,
21
94
  maxBuffer: 5 * 1024 * 1024,
@@ -242,8 +315,9 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
242
315
  caTask.outputPreview = agentOutput.slice(0, 500);
243
316
  caTask.liveOutput = undefined;
244
317
  writeCodeAgentTask(caTask);
318
+ const validationCmd = buildValidationCommand(workdir);
245
319
  const runValidationPromise = () => new Promise((res) => {
246
- const validationProc = exec('pnpm build && pnpm test', {
320
+ const validationProc = exec(validationCmd, {
247
321
  cwd: workdir,
248
322
  timeout: VALIDATE_TIMEOUT_MS,
249
323
  maxBuffer: 5 * 1024 * 1024,
@@ -2,6 +2,7 @@
2
2
  import { execSync } from 'child_process';
3
3
  import { resolve, join } from 'path';
4
4
  import { homedir } from 'os';
5
+ import { buildValidationCommand } from './executor.js';
5
6
  // Resolve CLI paths once at import time so spawn doesn't get ENOENT
6
7
  function resolveCliPath(name) {
7
8
  try {
@@ -113,7 +114,7 @@ export function buildCodeAgentArgs(input) {
113
114
  '--mcp-config', playwrightMcp,
114
115
  ...toolArgs,
115
116
  '--max-turns', maxTurns,
116
- '--append-system-prompt', 'Output text only. Never use say or TTS. Focus on the coding task. Run pnpm build && pnpm test to verify changes.',
117
+ '--append-system-prompt', `Output text only. Never use say or TTS. Focus on the coding task. Run ${buildValidationCommand(input.workdir || process.cwd())} to verify changes.`,
117
118
  ];
118
119
  // Only pass model to Claude CLI if it's not a known non-Claude model.
119
120
  // GPT/Codex/Kimi/o-series models would be rejected by the Claude CLI.
@@ -152,6 +153,10 @@ export function buildTeamNotification(task, getChildTask) {
152
153
  lines.push(` ${childIcon} ${child.id} (${childDur}): ${subtask}`);
153
154
  }
154
155
  }
156
+ // Synthesis result
157
+ if (task.outputPreview) {
158
+ lines.push(`\nResult: ${task.outputPreview}`);
159
+ }
155
160
  // Errors
156
161
  if (task.error && task.error !== 'Validation failed') {
157
162
  lines.push(`\nError: ${task.error}`);
@@ -169,8 +174,7 @@ export function buildSoloNotification(task) {
169
174
  const validation = task.validationPassed ? ' Build/tests pass.' : '';
170
175
  let message = `✅ Coding agent ${task.id} completed (${dur}).${validation}\n\nTask: ${taskPreview}`;
171
176
  if (task.outputPreview) {
172
- const preview = task.outputPreview.slice(0, 300);
173
- message += `\n\nResult: ${preview}`;
177
+ message += `\n\nResult: ${task.outputPreview}`;
174
178
  }
175
179
  return message;
176
180
  }
@@ -12,6 +12,7 @@ export declare function checkTelegramToken(token: string): Promise<DoctorCheckRe
12
12
  export declare function checkDiscordToken(token: string): Promise<DoctorCheckResult>;
13
13
  export declare function checkBrowserBinaryIfEnabled(config: Config): Promise<DoctorCheckResult>;
14
14
  export declare function checkVoiceDependencies(config: Config): Promise<DoctorCheckResult>;
15
+ export declare function checkPlaywrightIfBrowserEnabled(config: Config): Promise<DoctorCheckResult>;
15
16
  export declare function checkMcpConfig(config: Config): Promise<DoctorCheckResult>;
16
17
  export declare function checkGatewayHostBindable(host: string): Promise<DoctorCheckResult>;
17
18
  export declare function checkSkimpyclawDirWritable(): Promise<DoctorCheckResult>;
@@ -274,16 +274,32 @@ export async function checkVoiceDependencies(config) {
274
274
  if (ffmpeg.status !== 0) {
275
275
  issues.push('ffmpeg not found');
276
276
  }
277
- // Check for whisper-cli (C++) or whisper (Python)
277
+ // Check for STT: local whisper OR API provider
278
278
  const whisperCli = spawnSync('which', ['whisper-cli'], { encoding: 'utf-8' });
279
279
  const whisperPy = spawnSync('which', ['whisper'], { encoding: 'utf-8' });
280
- if (whisperCli.status !== 0 && whisperPy.status !== 0) {
281
- issues.push('whisper not found (neither whisper-cli nor whisper)');
280
+ const hasLocalWhisper = whisperCli.status === 0 || whisperPy.status === 0;
281
+ // Check if any API STT provider is configured
282
+ const hasApiStt = Object.values(config.voice?.providers || {}).some((p) => p && typeof p === 'object' && 'stt' in p);
283
+ if (!hasLocalWhisper && !hasApiStt) {
284
+ issues.push('No STT available — install whisper-cli (brew install whisper-cpp) or configure an API STT provider (e.g. openai.stt)');
282
285
  }
283
286
  if (issues.length > 0) {
284
- return fail(name, category, issues.join('; '), 'Install ffmpeg and whisper-cli (or whisper) for voice features.');
287
+ return fail(name, category, issues.join('; '), 'Install ffmpeg, and either whisper-cli (brew install whisper-cpp) or add openai.stt to voice providers.');
285
288
  }
286
- return ok(name, category, 'ffmpeg and whisper available');
289
+ const sttMethod = hasLocalWhisper ? (whisperCli.status === 0 ? 'whisper-cli' : 'whisper') : 'API STT';
290
+ return ok(name, category, `ffmpeg and ${sttMethod} available`);
291
+ }
292
+ export async function checkPlaywrightIfBrowserEnabled(config) {
293
+ const name = 'playwright_installed';
294
+ const category = 'runtime';
295
+ if (!isAnyBrowserEnabled(config)) {
296
+ return ok(name, category, 'Browser tools disabled');
297
+ }
298
+ const pw = spawnSync('npx', ['playwright', '--version'], { encoding: 'utf-8', timeout: 10000 });
299
+ if (pw.status === 0) {
300
+ return ok(name, category, `Playwright ${(pw.stdout || '').trim()}`);
301
+ }
302
+ return fail(name, category, 'Playwright not installed', 'Run: npx playwright install chromium');
287
303
  }
288
304
  export async function checkMcpConfig(config) {
289
305
  const name = 'mcp_config';
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from '../config.js';
2
- import { checkNodeVersion, checkPackageManagerAvailable, checkTypeScriptCompile, checkConfigExistsAndValidJson, checkRequiredEnvVars, checkEnvVarPatterns, checkAllowedPathsWritable, checkProviderAuth, checkTelegramToken, checkDiscordToken, checkBrowserBinaryIfEnabled, checkVoiceDependencies, checkMcpConfig, checkGatewayHostBindable, checkSkimpyclawDirWritable, checkPortAvailability, checkSandboxAvailable, } from './checks.js';
2
+ import { checkNodeVersion, checkPackageManagerAvailable, checkTypeScriptCompile, checkConfigExistsAndValidJson, checkRequiredEnvVars, checkEnvVarPatterns, checkAllowedPathsWritable, checkProviderAuth, checkTelegramToken, checkDiscordToken, checkBrowserBinaryIfEnabled, checkPlaywrightIfBrowserEnabled, checkVoiceDependencies, checkMcpConfig, checkGatewayHostBindable, checkSkimpyclawDirWritable, checkPortAvailability, checkSandboxAvailable, } from './checks.js';
3
3
  export function computeExitCode(report) {
4
4
  if (report.checks.some((check) => !check.ok && check.fatal)) {
5
5
  return 2;
@@ -99,6 +99,7 @@ export async function runDoctor() {
99
99
  }
100
100
  }
101
101
  checks.push(await runSafe('browser_binary_available', 'runtime', () => checkBrowserBinaryIfEnabled(config)));
102
+ checks.push(await runSafe('playwright_installed', 'runtime', () => checkPlaywrightIfBrowserEnabled(config)));
102
103
  checks.push(await runSafe('voice_dependencies', 'runtime', () => checkVoiceDependencies(config)));
103
104
  checks.push(await runSafe('mcp_config', 'runtime', () => checkMcpConfig(config)));
104
105
  checks.push(await runSafe('gateway_host_bindable', 'runtime', () => checkGatewayHostBindable(config.gateway.host ?? '127.0.0.1')));
package/dist/setup.d.ts CHANGED
@@ -21,8 +21,9 @@ interface SetupStarters {
21
21
  cronWeather: boolean;
22
22
  timezone: string;
23
23
  weatherLocation: string;
24
- skillCodeReview: boolean;
25
24
  skillDailyNotes: boolean;
25
+ skillWeather: boolean;
26
+ skillWebSearch: boolean;
26
27
  }
27
28
  interface SetupBuildInput {
28
29
  workspaceDir: string;
package/dist/setup.js CHANGED
@@ -460,17 +460,20 @@ export function buildSetupConfig(input) {
460
460
  cronWeather: false,
461
461
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
462
462
  weatherLocation: 'New York, NY',
463
- skillCodeReview: false,
464
463
  skillDailyNotes: false,
464
+ skillWeather: false,
465
+ skillWebSearch: false,
465
466
  };
466
467
  const basePaths = ['${HOME}/.skimpyclaw'];
467
468
  const allPaths = [...basePaths, ...(input.extraAllowedPaths || [])];
468
469
  const starterCronJobs = buildStarterCronJobs(starters);
469
470
  const starterSkillEntries = {};
470
- if (starters.skillCodeReview)
471
- starterSkillEntries['code-review'] = true;
472
471
  if (starters.skillDailyNotes)
473
472
  starterSkillEntries['daily-notes'] = true;
473
+ if (starters.skillWeather)
474
+ starterSkillEntries['weather'] = true;
475
+ if (starters.skillWebSearch)
476
+ starterSkillEntries['web-search'] = true;
474
477
  return {
475
478
  gateway: {
476
479
  port: 18790,
@@ -595,19 +598,6 @@ const REQUIRED_TEMPLATE_DEFAULTS = {
595
598
  'HEARTBEAT.md': '# HEARTBEAT\n\nIf nothing needs attention, reply HEARTBEAT_OK.\n',
596
599
  };
597
600
  const STARTER_SKILL_TEMPLATES = {
598
- 'code-review': `---
599
- name: code-review
600
- description: Structured code review checklist for bugs, regressions, and missing tests.
601
- triggers: ["review", "pr", "regression", "tests"]
602
- priority: 80
603
- ---
604
-
605
- When asked to review code:
606
- 1. Focus on correctness and regressions first.
607
- 2. Call out missing or weak test coverage.
608
- 3. Prefer concrete file-level findings.
609
- 4. End with risk summary and recommended fixes.
610
- `,
611
601
  'daily-notes': `---
612
602
  name: daily-notes
613
603
  description: Keep daily notes organized under the configured daily notes directory.
@@ -620,6 +610,36 @@ When writing daily notes:
620
610
  2. Include sections: Priorities, Schedule, Notes, Follow-ups.
621
611
  3. Keep entries concise and actionable.
622
612
  4. Avoid creating files outside the configured daily notes directory.
613
+ `,
614
+ 'weather': `---
615
+ name: weather
616
+ description: Fetch and format weather data for daily briefings and quick checks.
617
+ triggers: ["weather", "forecast", "temperature", "rain"]
618
+ priority: 45
619
+ ---
620
+
621
+ When asked about weather or generating a daily briefing:
622
+ 1. Use web search to find current weather for the user's location.
623
+ 2. Format as: conditions, high/low temps, precipitation chance.
624
+ 3. Keep it to 2-3 sentences max.
625
+ 4. Include any weather alerts if present.
626
+ 5. For daily briefings: mention if rain is expected (affects outdoor plans).
627
+ `,
628
+ 'web-search': `---
629
+ name: web-search
630
+ description: Search the web using the Browser tool. Opens DuckDuckGo, reads results, and returns findings.
631
+ triggers: ["search", "look up", "google", "find online", "web search"]
632
+ priority: 50
633
+ ---
634
+
635
+ When asked to search the web:
636
+ 1. Use the Browser tool to open https://html.duckduckgo.com/html/?q=<URL-encoded query>
637
+ 2. Use getText to read the search results page.
638
+ 3. If a specific result looks promising, open that URL and extract the relevant content.
639
+ 4. Summarize findings concisely — include source URLs.
640
+ 5. Close the browser when done.
641
+
642
+ Do NOT fabricate results. If the search returns nothing useful, say so.
623
643
  `,
624
644
  };
625
645
  function ensureCoreTemplates(agentDir) {
@@ -638,10 +658,12 @@ function ensureStarterSkills(starters) {
638
658
  const skillsDir = join(CONFIG_DIR, 'skills');
639
659
  mkdirSync(skillsDir, { recursive: true });
640
660
  const requested = [];
641
- if (starters.skillCodeReview)
642
- requested.push('code-review');
643
661
  if (starters.skillDailyNotes)
644
662
  requested.push('daily-notes');
663
+ if (starters.skillWeather)
664
+ requested.push('weather');
665
+ if (starters.skillWebSearch)
666
+ requested.push('web-search');
645
667
  for (const skillName of requested) {
646
668
  const dir = join(skillsDir, skillName);
647
669
  const skillPath = join(dir, 'SKILL.md');
@@ -876,6 +898,22 @@ export async function runSetup(options = {}) {
876
898
  else {
877
899
  statusWarn('Chrome not found — browser tool may not work until Chrome is installed');
878
900
  }
901
+ // Check for Playwright
902
+ const pw = spawnSync('npx', ['playwright', '--version'], { encoding: 'utf-8', timeout: 10000 });
903
+ if (pw.status === 0) {
904
+ statusOk('Playwright detected');
905
+ }
906
+ else {
907
+ console.log('');
908
+ console.log(' ┌─────────────────────────────────────────────────────────┐');
909
+ console.log(' │ Browser tool requires Playwright. Install it: │');
910
+ console.log(' │ │');
911
+ console.log(' │ npx playwright install chromium │');
912
+ console.log(' │ │');
913
+ console.log(' │ Without this, the browser tool will fail at runtime. │');
914
+ console.log(' └─────────────────────────────────────────────────────────┘');
915
+ console.log('');
916
+ }
879
917
  }
880
918
  else {
881
919
  statusOk('browser disabled');
@@ -891,6 +929,32 @@ export async function runSetup(options = {}) {
891
929
  else {
892
930
  statusWarn('ffmpeg not found — voice features may not work until ffmpeg is installed');
893
931
  }
932
+ // Check for STT (speech-to-text) capability
933
+ const whisperCli = spawnSync('which', ['whisper-cli'], { encoding: 'utf-8' });
934
+ const whisperPy = spawnSync('which', ['whisper'], { encoding: 'utf-8' });
935
+ if (whisperCli.status === 0) {
936
+ statusOk('whisper-cli detected (whisper.cpp)');
937
+ }
938
+ else if (whisperPy.status === 0) {
939
+ statusOk('whisper detected (Python)');
940
+ }
941
+ else {
942
+ console.log('');
943
+ console.log(' ┌─────────────────────────────────────────────────────────┐');
944
+ console.log(' │ Voice transcription (STT) requires one of: │');
945
+ console.log(' │ │');
946
+ console.log(' │ Option A: Local whisper.cpp (free, recommended) │');
947
+ console.log(' │ brew install whisper-cpp │');
948
+ console.log(' │ whisper-cpp-download-ggml-model small │');
949
+ console.log(' │ │');
950
+ console.log(' │ Option B: OpenAI Whisper API ($0.006/min) │');
951
+ console.log(' │ Add OPENAI_API_KEY to ~/.skimpyclaw/.env │');
952
+ console.log(' │ Config auto-includes openai.stt if provider selected │');
953
+ console.log(' │ │');
954
+ console.log(' │ Without either, voice messages cannot be transcribed. │');
955
+ console.log(' └─────────────────────────────────────────────────────────┘');
956
+ console.log('');
957
+ }
894
958
  }
895
959
  else {
896
960
  statusOk('voice disabled');
@@ -953,15 +1017,17 @@ export async function runSetup(options = {}) {
953
1017
  const locationInput = await ask(rl, ' Weather location (city, state/country) [New York, NY]: ');
954
1018
  weatherLocation = locationInput || 'New York, NY';
955
1019
  }
956
- const addCodeReviewSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: code-review? [y/N]: '));
957
1020
  const addDailyNotesSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: daily-notes? [y/N]: '));
1021
+ const addWeatherSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: weather? [y/N]: '));
1022
+ const addWebSearchSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: web-search (uses Browser tool)? [y/N]: '));
958
1023
  const starters = {
959
1024
  cronTechNews: addTechNewsCron,
960
1025
  cronWeather: addWeatherCron,
961
1026
  timezone: cronTimezone,
962
1027
  weatherLocation,
963
- skillCodeReview: addCodeReviewSkill,
964
1028
  skillDailyNotes: addDailyNotesSkill,
1029
+ skillWeather: addWeatherSkill,
1030
+ skillWebSearch: addWebSearchSkill,
965
1031
  };
966
1032
  const { envContent, config: generatedConfig } = buildSetupArtifacts({
967
1033
  workspaceDir: extraAllowedPaths[0] || join(homedir(), '.skimpyclaw'),
@@ -97,7 +97,7 @@ async function ensureBrowser(config, overrides) {
97
97
  '--no-first-run',
98
98
  '--no-default-browser-check',
99
99
  ],
100
- ignoreDefaultArgs: ['--enable-automation'],
100
+ ignoreDefaultArgs: ['--enable-automation', '--no-sandbox'],
101
101
  });
102
102
  }
103
103
  catch (err) {
@@ -142,7 +142,7 @@ export const CODE_WITH_AGENT_TOOL = {
142
142
  model: { type: 'string', description: 'Model override (e.g. opus, gpt-5.3-codex)' },
143
143
  max_turns: { type: 'number', description: 'Max agentic turns, Claude only (default: 30)' },
144
144
  timeout_minutes: { type: 'number', description: 'Timeout in minutes (default: 10, max: 30)' },
145
- validate: { type: 'boolean', description: 'Run pnpm build && pnpm test after (default: true)' },
145
+ validate: { type: 'boolean', description: 'Run build && test after completion using the auto-detected package manager (default: true)' },
146
146
  },
147
147
  required: ['task'],
148
148
  },
@@ -159,7 +159,7 @@ export const CODE_WITH_TEAM_TOOL = {
159
159
  agent: { type: 'string', enum: ['claude', 'codex', 'kimi'], description: 'Which coding CLI to use for all team workers. Omit to use configured default.' },
160
160
  model: { type: 'string', description: 'Model override (e.g. claude-sonnet-4-5, gpt-5.3-codex)' },
161
161
  timeout_minutes: { type: 'number', description: 'Total timeout in minutes (default: 20, max: 60)' },
162
- validate: { type: 'boolean', description: 'Run pnpm build && pnpm test after all agents complete (default: true)' },
162
+ validate: { type: 'boolean', description: 'Run build && test after all agents complete using the auto-detected package manager (default: true)' },
163
163
  },
164
164
  required: ['task'],
165
165
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,30 +10,12 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "sandbox",
13
14
  "templates",
14
15
  "com.skimpyclaw.gateway.plist.example",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
18
- "scripts": {
19
- "cli": "tsx src/cli.ts",
20
- "start": "tsx src/index.ts",
21
- "dev": "tsx watch src/index.ts",
22
- "dashboard:dev": "pnpm --dir web/dashboard dev",
23
- "dashboard:build": "pnpm --dir web/dashboard install --frozen-lockfile && pnpm --dir web/dashboard build",
24
- "docs:dev": "pnpm --dir docs dev",
25
- "docs:build": "pnpm --dir docs install --frozen-lockfile && pnpm --dir docs build",
26
- "docs:preview": "pnpm --dir docs preview",
27
- "setup": "tsx src/setup.ts",
28
- "onboard": "tsx src/cli.ts onboard",
29
- "build": "tsc && pnpm dashboard:build",
30
- "release:check": "pnpm build && pnpm test",
31
- "release:local": "bash ./scripts/release.sh",
32
- "lint": "eslint \"src/**/*.ts\"",
33
- "typecheck": "tsc --noEmit",
34
- "test": "vitest run",
35
- "ci": "pnpm run lint && pnpm run typecheck && pnpm run test"
36
- },
37
19
  "dependencies": {
38
20
  "@anthropic-ai/sdk": "^0.52.0",
39
21
  "@grammyjs/runner": "^2.0.3",
@@ -55,11 +37,6 @@
55
37
  "openai": "^4.47.0",
56
38
  "playwright": "^1.49.0"
57
39
  },
58
- "pnpm": {
59
- "onlyBuiltDependencies": [
60
- "esbuild"
61
- ]
62
- },
63
40
  "devDependencies": {
64
41
  "@eslint/js": "^9.39.2",
65
42
  "@types/node": "^20.11.0",
@@ -69,5 +46,24 @@
69
46
  "typescript": "^5.4.0",
70
47
  "typescript-eslint": "^8.54.0",
71
48
  "vitest": "^4.0.18"
49
+ },
50
+ "scripts": {
51
+ "cli": "tsx src/cli.ts",
52
+ "start": "tsx src/index.ts",
53
+ "dev": "tsx watch src/index.ts",
54
+ "dashboard:dev": "pnpm --dir web/dashboard dev",
55
+ "dashboard:build": "pnpm --dir web/dashboard install --frozen-lockfile && pnpm --dir web/dashboard build",
56
+ "docs:dev": "pnpm --dir docs dev",
57
+ "docs:build": "pnpm --dir docs install --frozen-lockfile && pnpm --dir docs build",
58
+ "docs:preview": "pnpm --dir docs preview",
59
+ "setup": "tsx src/setup.ts",
60
+ "onboard": "tsx src/cli.ts onboard",
61
+ "build": "tsc && pnpm dashboard:build",
62
+ "release:check": "pnpm build && pnpm test",
63
+ "release:local": "bash ./scripts/release.sh",
64
+ "lint": "eslint \"src/**/*.ts\"",
65
+ "typecheck": "tsc --noEmit",
66
+ "test": "vitest run",
67
+ "ci": "pnpm run lint && pnpm run typecheck && pnpm run test"
72
68
  }
73
- }
69
+ }
@@ -0,0 +1,40 @@
1
+ FROM node:22-slim
2
+
3
+ ARG SKIMPY_PROFILE=minimal
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ bash \
7
+ ca-certificates \
8
+ curl \
9
+ git \
10
+ gnupg \
11
+ jq \
12
+ python3 \
13
+ ripgrep \
14
+ && mkdir -p /etc/apt/keyrings \
15
+ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
16
+ | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
17
+ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
18
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
19
+ > /etc/apt/sources.list.d/github-cli.list \
20
+ && apt-get update \
21
+ && apt-get install -y --no-install-recommends gh \
22
+ && if [ "$SKIMPY_PROFILE" = "dev" ] || [ "$SKIMPY_PROFILE" = "full" ]; then \
23
+ apt-get install -y --no-install-recommends build-essential make gcc g++ pkg-config; \
24
+ fi \
25
+ && if [ "$SKIMPY_PROFILE" = "full" ]; then \
26
+ apt-get install -y --no-install-recommends python3-pip sqlite3 unzip less; \
27
+ fi \
28
+ && rm -rf /var/lib/apt/lists/*
29
+
30
+ # Install pnpm globally (Claude Code not needed inside sandbox)
31
+ RUN npm install -g pnpm
32
+
33
+ # Create non-root user matching typical macOS uid/gid
34
+ RUN groupadd -g 20 sandboxgrp || true \
35
+ && useradd -m -s /bin/bash -u 501 -g 20 sandbox || true
36
+
37
+ USER sandbox
38
+ WORKDIR /workspace
39
+
40
+ CMD ["sleep", "infinity"]