skimpyclaw 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -17
- package/dist/__tests__/api.test.js +19 -0
- package/dist/__tests__/doctor.runner.test.js +5 -1
- package/dist/__tests__/package-manager-detection.test.d.ts +1 -0
- package/dist/__tests__/package-manager-detection.test.js +172 -0
- package/dist/api.js +27 -0
- package/dist/cli.js +8 -0
- package/dist/code-agents/executor.d.ts +16 -0
- package/dist/code-agents/executor.js +77 -3
- package/dist/code-agents/utils.js +7 -3
- package/dist/cron.js +8 -1
- package/dist/dashboard/assets/index-UVAjSXCG.js +107 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/doctor/checks.d.ts +1 -0
- package/dist/doctor/checks.js +21 -5
- package/dist/doctor/runner.js +2 -1
- package/dist/setup.js +42 -0
- package/dist/tools/definitions.js +2 -2
- package/package.json +22 -26
- package/sandbox/Dockerfile +40 -0
- package/dist/dashboard/assets/index-CkonC7Cd.js +0 -65
package/README.md
CHANGED
|
@@ -74,16 +74,10 @@ See [docs/architecture.md](docs/architecture.md) for runtime flow and startup se
|
|
|
74
74
|
|
|
75
75
|
## Quick Start
|
|
76
76
|
|
|
77
|
-
**Install:**
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
npm install -g skimpyclaw
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
**Run onboarding:**
|
|
84
|
-
|
|
85
77
|
```bash
|
|
78
|
+
pnpm add -g skimpyclaw
|
|
86
79
|
skimpyclaw onboard
|
|
80
|
+
skimpyclaw start --daemon
|
|
87
81
|
```
|
|
88
82
|
|
|
89
83
|
Onboarding validates your Telegram token, provider auth, and creates:
|
|
@@ -91,14 +85,6 @@ Onboarding validates your Telegram token, provider auth, and creates:
|
|
|
91
85
|
- `~/.skimpyclaw/config.json`
|
|
92
86
|
- `~/.skimpyclaw/agents/main/*.md` (from templates)
|
|
93
87
|
|
|
94
|
-
**Start:**
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
skimpyclaw start
|
|
98
|
-
# or run as launchd daemon on macOS:
|
|
99
|
-
skimpyclaw start --daemon
|
|
100
|
-
```
|
|
101
|
-
|
|
102
88
|
**Stop/Restart daemon (macOS):**
|
|
103
89
|
|
|
104
90
|
```bash
|
|
@@ -217,7 +203,7 @@ dist/ # Compiled output + built dashboard assets
|
|
|
217
203
|
| [docs/chat-commands.md](docs/chat-commands.md) | Telegram/Discord bot commands |
|
|
218
204
|
| [docs/skills.md](docs/skills.md) | Skills system, built-in skills, creating custom skills |
|
|
219
205
|
| [docs/data-storage.md](docs/data-storage.md) | File layout, audit log format, security notes |
|
|
220
|
-
| [
|
|
206
|
+
| [Setup Guide](https://docs.skimpyclaw.xyz/guide/setup-guide.html) | Step-by-step installation and setup guide |
|
|
221
207
|
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues and solutions |
|
|
222
208
|
|
|
223
209
|
## Development
|
|
@@ -1225,3 +1225,22 @@ describe('Skills endpoints', () => {
|
|
|
1225
1225
|
expect(res.json()).toHaveProperty('deleted', true);
|
|
1226
1226
|
});
|
|
1227
1227
|
});
|
|
1228
|
+
describe('Office endpoints', () => {
|
|
1229
|
+
it('GET /api/dashboard/office-status returns office status', async () => {
|
|
1230
|
+
const res = await inject({ method: 'GET', url: '/api/dashboard/office-status' });
|
|
1231
|
+
expect(res.statusCode).toBe(200);
|
|
1232
|
+
const json = res.json();
|
|
1233
|
+
expect(json).toHaveProperty('state');
|
|
1234
|
+
expect(json).toHaveProperty('currentTask');
|
|
1235
|
+
expect(json).toHaveProperty('updatedAt');
|
|
1236
|
+
expect(['working', 'thinking', 'waiting', 'offline']).toContain(json.state);
|
|
1237
|
+
});
|
|
1238
|
+
it('GET /api/dashboard/office-scene.png returns image or 404', async () => {
|
|
1239
|
+
const res = await inject({ method: 'GET', url: '/api/dashboard/office-scene.png' });
|
|
1240
|
+
// In test env the image file may not exist, so accept 200 or 404
|
|
1241
|
+
expect([200, 404]).toContain(res.statusCode);
|
|
1242
|
+
if (res.statusCode === 200) {
|
|
1243
|
+
expect(res.headers['content-type']).toBe('image/png');
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
@@ -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
|
+
});
|
package/dist/api.js
CHANGED
|
@@ -785,6 +785,33 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
785
785
|
}
|
|
786
786
|
return { denied: true, id, command: approval.command };
|
|
787
787
|
});
|
|
788
|
+
// --- Office Status ---
|
|
789
|
+
fastify.get('/api/dashboard/office-status', async (request) => {
|
|
790
|
+
// Phase 1: return mock data
|
|
791
|
+
return {
|
|
792
|
+
state: 'working',
|
|
793
|
+
currentTask: 'Processing dashboard updates',
|
|
794
|
+
updatedAt: new Date().toISOString(),
|
|
795
|
+
};
|
|
796
|
+
});
|
|
797
|
+
// --- Office Scene Image ---
|
|
798
|
+
fastify.get('/api/dashboard/office-scene.png', async (_request, reply) => {
|
|
799
|
+
const home = homedir();
|
|
800
|
+
const candidates = [
|
|
801
|
+
join(home, '.skimpyclaw', 'office-preview-check.png'),
|
|
802
|
+
join(home, '.skimpyclaw', 'office-offline.png'),
|
|
803
|
+
// Container/workspace fallbacks (e.g. Codespaces, Docker where HOME=/workspace)
|
|
804
|
+
'/workspace/.skimpyclaw/office-preview-check.png',
|
|
805
|
+
'/workspace/.skimpyclaw/office-offline.png',
|
|
806
|
+
].filter((p, i, arr) => arr.indexOf(p) === i); // dedupe if home IS /workspace
|
|
807
|
+
for (const candidate of candidates) {
|
|
808
|
+
if (existsSync(candidate)) {
|
|
809
|
+
const buffer = readFileSync(candidate);
|
|
810
|
+
return reply.type('image/png').send(buffer);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return reply.code(404).send({ error: 'Office scene image not found' });
|
|
814
|
+
});
|
|
788
815
|
// --- Digests ---
|
|
789
816
|
fastify.get('/api/dashboard/digests', async () => {
|
|
790
817
|
const digests = getDigests();
|
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) {
|
|
@@ -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(
|
|
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(
|
|
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',
|
|
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
|
-
|
|
173
|
-
message += `\n\nResult: ${preview}`;
|
|
177
|
+
message += `\n\nResult: ${task.outputPreview}`;
|
|
174
178
|
}
|
|
175
179
|
return message;
|
|
176
180
|
}
|
package/dist/cron.js
CHANGED
|
@@ -4,6 +4,7 @@ import { exec } from 'child_process';
|
|
|
4
4
|
import { existsSync, mkdirSync, appendFileSync, readFileSync, watch } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { getLogsDir, getConfigPath, loadConfig } from './config.js';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
7
8
|
import { runAgentTurn } from './agent.js';
|
|
8
9
|
import { startTrace, addEvent, endTrace } from './audit.js';
|
|
9
10
|
import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
|
|
@@ -135,7 +136,13 @@ async function executeJobPayload(jobDef, config) {
|
|
|
135
136
|
if (jobDef.payload.kind === 'agentTurn') {
|
|
136
137
|
const message = expandVariables(resolveMessageSource(jobDef.payload.message || ''));
|
|
137
138
|
appendCronLogLine(jobDef.id, `Agent turn started (prompt: ${message.slice(0, 100)}...)`);
|
|
138
|
-
const
|
|
139
|
+
const defaultTools = {
|
|
140
|
+
enabled: true,
|
|
141
|
+
allowedPaths: [`${homedir()}/.skimpyclaw`],
|
|
142
|
+
maxIterations: 30,
|
|
143
|
+
bashTimeout: 15000,
|
|
144
|
+
};
|
|
145
|
+
const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, jobDef.payload.tools || defaultTools, undefined, {
|
|
139
146
|
channel: getActiveChannelId() || 'telegram',
|
|
140
147
|
trigger: 'cron',
|
|
141
148
|
sessionId: jobDef.id,
|