openthrottle 0.0.1 → 0.1.1

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 ADDED
@@ -0,0 +1,39 @@
1
+ # openthrottle
2
+
3
+ CLI for [Open Throttle](https://github.com/knoxgraeme/openthrottle) — ship prompts to autonomous coding agents running on Daytona sandboxes.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx openthrottle init # one-time project setup
9
+ npx openthrottle ship docs/prds/auth.md # ship a prompt
10
+ npx openthrottle status # check running/queued tasks
11
+ npx openthrottle logs # view workflow runs
12
+ ```
13
+
14
+ ## Prerequisites
15
+
16
+ - [gh CLI](https://cli.github.com) installed and authenticated (`gh auth login`)
17
+ - A git repo with a GitHub remote
18
+ - `.openthrottle.yml` in the repo root (generated by `openthrottle init`)
19
+
20
+ ## Commands
21
+
22
+ | Command | What it does |
23
+ |---|---|
24
+ | `init` | Detect your project, generate config, create Daytona snapshot |
25
+ | `ship <file.md> [--base branch]` | Create a GitHub issue to trigger a sandbox |
26
+ | `status` | Show running, queued, reviewing, and completed tasks |
27
+ | `logs` | Show recent GitHub Actions workflow runs |
28
+
29
+ ## How it works
30
+
31
+ 1. `openthrottle init` generates `.openthrottle.yml` and a GitHub Actions workflow
32
+ 2. `openthrottle ship` creates a GitHub issue with the `prd-queued` label
33
+ 3. The workflow creates an ephemeral Daytona sandbox
34
+ 4. An agent (Claude, Codex, or Aider) implements the work and opens a PR
35
+ 5. Sandbox auto-deletes when done
36
+
37
+ ## License
38
+
39
+ MIT
package/index.mjs ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // openthrottle — CLI for Open Throttle.
4
+ //
5
+ // Usage: npx openthrottle <command>
6
+ //
7
+ // Commands:
8
+ // ship <file.md> [--base <branch>] Ship a prompt to a Daytona sandbox
9
+ // status Show running, queued, and completed tasks
10
+ // logs Show recent GitHub Actions workflow runs
11
+ // =============================================================================
12
+
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { join, resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // 1. Constants + helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const EXIT_OK = 0;
23
+ const EXIT_USER_ERROR = 1;
24
+ const EXIT_MISSING_DEP = 2;
25
+
26
+ function die(message, code = EXIT_USER_ERROR) {
27
+ console.error(`error: ${message}`);
28
+ process.exit(code);
29
+ }
30
+
31
+ function gh(args, { quiet = false } = {}) {
32
+ try {
33
+ return execFileSync('gh', args, {
34
+ encoding: 'utf8',
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ }).trim();
37
+ } catch (err) {
38
+ const stderr = err.stderr?.toString().trim() || '';
39
+ if (stderr.includes('auth login')) {
40
+ die('gh auth expired -- run: gh auth login', EXIT_MISSING_DEP);
41
+ }
42
+ if (quiet) {
43
+ // Exit code 1 with no stderr = no matching results (expected)
44
+ if (err.status === 1 && !stderr) return '';
45
+ // Real failure — warn but don't crash
46
+ console.error(`warning: gh ${args.slice(0, 2).join(' ')} failed: ${stderr || err.message}`);
47
+ return '';
48
+ }
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ function preflight() {
54
+ try {
55
+ execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
56
+ } catch {
57
+ die(
58
+ 'gh CLI not found or not authenticated.\n Install: https://cli.github.com\n Auth: gh auth login',
59
+ EXIT_MISSING_DEP,
60
+ );
61
+ }
62
+ }
63
+
64
+ function detectRepo() {
65
+ try {
66
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
67
+ encoding: 'utf8',
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ }).trim();
70
+ const match = url.match(/github\.com[:/](.+?\/.+?)(?:\.git)?$/);
71
+ if (match) return match[1];
72
+ } catch {}
73
+ die('Could not detect GitHub repo. Run from a git repo with a github.com remote.');
74
+ }
75
+
76
+ function readConfig() {
77
+ const configPath = join(process.cwd(), '.openthrottle.yml');
78
+ if (!existsSync(configPath)) {
79
+ return { baseBranch: 'main', snapshot: 'openthrottle' };
80
+ }
81
+ let content;
82
+ try {
83
+ content = readFileSync(configPath, 'utf8');
84
+ } catch (err) {
85
+ die(`Could not read .openthrottle.yml: ${err.message}`);
86
+ }
87
+ const get = (key) => {
88
+ const match = content.match(new RegExp(`^${key}:\\s*(.+)`, 'm'));
89
+ if (!match) return undefined;
90
+ return match[1].replace(/#.*$/, '').trim().replace(/^["']|["']$/g, '');
91
+ };
92
+ return {
93
+ baseBranch: get('base_branch') || 'main',
94
+ snapshot: get('snapshot') || 'openthrottle',
95
+ };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // 2. Command: ship
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function cmdShip(args) {
103
+ let file = null;
104
+ let baseBranch = null;
105
+
106
+ for (let i = 0; i < args.length; i++) {
107
+ if (args[i] === '--base' && args[i + 1]) {
108
+ baseBranch = args[++i];
109
+ } else if (!file) {
110
+ file = args[i];
111
+ }
112
+ }
113
+
114
+ if (!file) die('Usage: openthrottle ship <file.md> [--base <branch>]');
115
+
116
+ file = resolve(file);
117
+ if (!existsSync(file)) die(`File not found: ${file}`);
118
+ if (!file.endsWith('.md')) die(`Expected a markdown file, got: ${file}`);
119
+
120
+ const config = readConfig();
121
+ const base = baseBranch || config.baseBranch;
122
+ const repo = detectRepo();
123
+
124
+ // Extract title from first markdown heading
125
+ const content = readFileSync(file, 'utf8');
126
+ const headingMatch = content.match(/^#{1,6}\s+(.+)/m);
127
+ let title = headingMatch ? headingMatch[1].trim() : file.replace(/\.md$/, '');
128
+ if (!title.startsWith('PRD:')) title = `PRD: ${title}`;
129
+
130
+ // Ensure labels exist (idempotent)
131
+ const labels = [
132
+ 'prd-queued', 'prd-running', 'prd-complete', 'prd-failed',
133
+ 'needs-review', 'reviewing',
134
+ 'bug-queued', 'bug-running', 'bug-complete', 'bug-failed',
135
+ ];
136
+ for (const label of labels) {
137
+ try {
138
+ gh(['label', 'create', label, '--repo', repo, '--force']);
139
+ } catch (err) {
140
+ const stderr = err.stderr?.toString().trim() || err.message;
141
+ console.error(`warning: failed to create label "${label}": ${stderr}`);
142
+ }
143
+ }
144
+
145
+ // Build label list
146
+ let issueLabels = 'prd-queued';
147
+ if (base !== 'main') issueLabels += `,base:${base}`;
148
+
149
+ // Create the issue
150
+ let issueUrl;
151
+ try {
152
+ issueUrl = gh([
153
+ 'issue', 'create',
154
+ '--repo', repo,
155
+ '--title', title,
156
+ '--body-file', file,
157
+ '--label', issueLabels,
158
+ ]);
159
+ } catch (err) {
160
+ const msg = err.stderr?.toString().trim() || err.message;
161
+ die(`Failed to create issue: ${msg}`);
162
+ }
163
+
164
+ // Show queue position
165
+ let queueCount = 0;
166
+ try {
167
+ const raw = gh([
168
+ 'issue', 'list', '--repo', repo,
169
+ '--label', 'prd-queued', '--state', 'open',
170
+ '--json', 'number', '--jq', 'length',
171
+ ]);
172
+ queueCount = parseInt(raw, 10) || 0;
173
+ } catch {}
174
+
175
+ let runningInfo = '';
176
+ try {
177
+ runningInfo = gh([
178
+ 'issue', 'list', '--repo', repo,
179
+ '--label', 'prd-running', '--state', 'open',
180
+ '--json', 'number,title',
181
+ '--jq', '.[0] | "#\\(.number) -- \\(.title)"',
182
+ ]);
183
+ } catch {}
184
+
185
+ console.log(`Shipped: ${issueUrl}`);
186
+ if (queueCount > 1) {
187
+ console.log(`Queue: ${queueCount} queued`);
188
+ } else {
189
+ console.log('Status: starting');
190
+ }
191
+ if (runningInfo) {
192
+ console.log(`Running: ${runningInfo}`);
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // 3. Command: status
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function cmdStatus() {
201
+ const repo = detectRepo();
202
+
203
+ console.log('RUNNING');
204
+ const running = gh([
205
+ 'issue', 'list', '--repo', repo,
206
+ '--label', 'prd-running', '--state', 'open',
207
+ '--json', 'number,title',
208
+ '--jq', '.[] | " #\\(.number) -- \\(.title)"',
209
+ ], { quiet: true });
210
+ console.log(running || ' (none)');
211
+
212
+ console.log('\nQUEUE');
213
+ const queued = gh([
214
+ 'issue', 'list', '--repo', repo,
215
+ '--label', 'prd-queued', '--state', 'open',
216
+ '--json', 'number,title',
217
+ '--jq', '.[] | " #\\(.number) -- \\(.title)"',
218
+ ], { quiet: true });
219
+ console.log(queued || ' (none)');
220
+
221
+ console.log('\nREVIEW');
222
+ const pending = gh([
223
+ 'pr', 'list', '--repo', repo,
224
+ '--label', 'needs-review',
225
+ '--json', 'number,title',
226
+ '--jq', '.[] | " pending: #\\(.number) -- \\(.title)"',
227
+ ], { quiet: true });
228
+ const reviewing = gh([
229
+ 'pr', 'list', '--repo', repo,
230
+ '--label', 'reviewing',
231
+ '--json', 'number,title',
232
+ '--jq', '.[] | " active: #\\(.number) -- \\(.title)"',
233
+ ], { quiet: true });
234
+ const fixes = gh([
235
+ 'pr', 'list', '--repo', repo,
236
+ '--search', 'review:changes_requested',
237
+ '--json', 'number,title',
238
+ '--jq', '.[] | " fixes: #\\(.number) -- \\(.title)"',
239
+ ], { quiet: true });
240
+ const reviewOutput = [pending, reviewing, fixes].filter(Boolean).join('\n');
241
+ console.log(reviewOutput || ' (none)');
242
+
243
+ console.log('\nCOMPLETED (recent)');
244
+ const completed = gh([
245
+ 'issue', 'list', '--repo', repo,
246
+ '--label', 'prd-complete', '--state', 'closed',
247
+ '--limit', '5',
248
+ '--json', 'number,title',
249
+ '--jq', '.[] | " #\\(.number) -- \\(.title)"',
250
+ ], { quiet: true });
251
+ console.log(completed || ' (none)');
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // 4. Command: logs
256
+ // ---------------------------------------------------------------------------
257
+
258
+ function cmdLogs() {
259
+ const repo = detectRepo();
260
+
261
+ let output;
262
+ try {
263
+ output = gh([
264
+ 'run', 'list',
265
+ '--repo', repo,
266
+ '--workflow', 'Wake Sandbox',
267
+ '--limit', '10',
268
+ ]);
269
+ } catch {
270
+ try {
271
+ output = gh([
272
+ 'run', 'list',
273
+ '--repo', repo,
274
+ '--limit', '10',
275
+ ]);
276
+ } catch (err) {
277
+ die(`Failed to list workflow runs: ${err.stderr?.toString().trim() || err.message}`);
278
+ }
279
+ }
280
+
281
+ if (!output) {
282
+ console.log('No workflow runs found.');
283
+ return;
284
+ }
285
+
286
+ console.log(output);
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // 5. Main
291
+ // ---------------------------------------------------------------------------
292
+
293
+ const HELP = `Usage: openthrottle <command>
294
+
295
+ Commands:
296
+ init Set up Open Throttle in your project
297
+ ship <file.md> [--base <branch>] Create a GitHub issue to trigger a sandbox
298
+ status Show running, queued, and completed tasks
299
+ logs Show recent GitHub Actions workflow runs
300
+
301
+ Options:
302
+ --help, -h Show this help message
303
+ --version, -v Show version`;
304
+
305
+ async function main() {
306
+ const args = process.argv.slice(2);
307
+ const command = args[0];
308
+
309
+ if (!command || command === '--help' || command === '-h') {
310
+ console.log(HELP);
311
+ process.exit(EXIT_OK);
312
+ }
313
+
314
+ if (command === '--version' || command === '-v') {
315
+ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
316
+ console.log(pkg.version);
317
+ process.exit(EXIT_OK);
318
+ }
319
+
320
+ if (command === 'init') {
321
+ const { default: init } = await import('./init.mjs');
322
+ await init();
323
+ return;
324
+ }
325
+
326
+ preflight();
327
+
328
+ switch (command) {
329
+ case 'ship':
330
+ cmdShip(args.slice(1));
331
+ break;
332
+ case 'status':
333
+ cmdStatus();
334
+ break;
335
+ case 'logs':
336
+ cmdLogs();
337
+ break;
338
+ default:
339
+ die(`Unknown command: ${command}\n Run "openthrottle --help" for usage.`);
340
+ }
341
+ }
342
+
343
+ main().catch((err) => {
344
+ console.error(`error: ${err.message}`);
345
+ process.exit(1);
346
+ });
package/init.mjs ADDED
@@ -0,0 +1,479 @@
1
+ // =============================================================================
2
+ // openthrottle init — Set up Open Throttle in any Node.js project.
3
+ //
4
+ // Detects the project, prompts for config, generates .openthrottle.yml +
5
+ // wake-sandbox.yml, creates a Daytona snapshot, and prints next steps.
6
+ // =============================================================================
7
+
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
9
+ import { join, dirname, relative } from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import prompts from 'prompts';
13
+ import { stringify } from 'yaml';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const cwd = process.cwd();
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // 1. Detect project
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function detectProject() {
23
+ const pkgPath = join(cwd, 'package.json');
24
+ if (!existsSync(pkgPath)) {
25
+ console.error('No package.json found. openthrottle init currently supports Node.js projects only.');
26
+ process.exit(1);
27
+ }
28
+
29
+ let pkg;
30
+ try {
31
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
32
+ } catch {
33
+ console.error('Could not parse package.json. Is it valid JSON?');
34
+ process.exit(1);
35
+ }
36
+ const scripts = pkg.scripts || {};
37
+ const rawName = pkg.name?.replace(/^@[^/]+\//, '') || 'project';
38
+ const name = rawName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
39
+
40
+ // Detect package manager
41
+ let pm = 'npm';
42
+ if (pkg.packageManager?.startsWith('pnpm')) pm = 'pnpm';
43
+ else if (pkg.packageManager?.startsWith('yarn')) pm = 'yarn';
44
+ else if (existsSync(join(cwd, 'pnpm-lock.yaml'))) pm = 'pnpm';
45
+ else if (existsSync(join(cwd, 'yarn.lock'))) pm = 'yarn';
46
+ else if (existsSync(join(cwd, 'package-lock.json'))) pm = 'npm';
47
+
48
+ // Detect base branch
49
+ let baseBranch = 'main';
50
+ try {
51
+ const head = execFileSync('git', ['remote', 'show', 'origin'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
52
+ const match = head.match(/HEAD branch:\s*(\S+)/);
53
+ if (match) baseBranch = match[1];
54
+ } catch {
55
+ // Not a git repo or no remote — default to main
56
+ }
57
+
58
+ return {
59
+ name,
60
+ pm,
61
+ baseBranch,
62
+ test: scripts.test ? `${pm} test` : '',
63
+ build: scripts.build ? `${pm} build` : '',
64
+ lint: scripts.lint ? `${pm} lint` : '',
65
+ format: scripts.format ? `${pm} run format` : (pkg.devDependencies?.prettier ? 'npx prettier --write .' : ''),
66
+ dev: scripts.dev ? `${pm} dev --port 8080 --hostname 0.0.0.0` : '',
67
+ };
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // 1b. Detect .env files and extract key names
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function detectEnvFiles() {
75
+ const envFiles = {};
76
+ const seen = new Set();
77
+
78
+ function scan(dir) {
79
+ let entries;
80
+ try { entries = readdirSync(dir); } catch { return; }
81
+ for (const entry of entries) {
82
+ if (entry === 'node_modules' || entry === '.git' || entry === '.next' || entry === 'dist') continue;
83
+ const full = join(dir, entry);
84
+ let stat;
85
+ try { stat = statSync(full); } catch { continue; }
86
+ if (stat.isDirectory()) { scan(full); continue; }
87
+ if (!entry.startsWith('.env')) continue;
88
+ // Skip .env.example, .env.sample, .env.template
89
+ if (/\.(example|sample|template)$/i.test(entry)) continue;
90
+
91
+ const relPath = relative(cwd, full);
92
+ const keys = [];
93
+ try {
94
+ const content = readFileSync(full, 'utf8');
95
+ for (const line of content.split('\n')) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed || trimmed.startsWith('#')) continue;
98
+ const match = trimmed.replace(/^export\s+/, '').match(/^([a-zA-Z_][a-zA-Z0-9_]*)=/);
99
+ if (match) keys.push(match[1]);
100
+ }
101
+ } catch (err) {
102
+ console.error(`warning: could not read ${relPath}: ${err.message}`);
103
+ continue;
104
+ }
105
+
106
+ if (keys.length > 0) {
107
+ envFiles[relPath] = keys;
108
+ keys.forEach(k => seen.add(k));
109
+ }
110
+ }
111
+ }
112
+
113
+ scan(cwd);
114
+ return { envFiles, allKeys: [...seen].sort() };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 2. Prompt for config
119
+ // ---------------------------------------------------------------------------
120
+
121
+ async function promptConfig(detected) {
122
+ console.log(`\n Detected: package.json (${detected.pm})\n`);
123
+
124
+ const response = await prompts([
125
+ { type: 'text', name: 'baseBranch', message: 'Base branch', initial: detected.baseBranch },
126
+ { type: 'text', name: 'test', message: 'Test command', initial: detected.test },
127
+ { type: 'text', name: 'build', message: 'Build command', initial: detected.build },
128
+ { type: 'text', name: 'lint', message: 'Lint command', initial: detected.lint },
129
+ { type: 'text', name: 'format', message: 'Format command', initial: detected.format },
130
+ { type: 'text', name: 'dev', message: 'Dev command', initial: detected.dev },
131
+ { type: 'text', name: 'postBootstrap', message: 'Post-bootstrap command', initial: `${detected.pm} install` },
132
+ {
133
+ type: 'select', name: 'agent', message: 'Agent runtime',
134
+ choices: [
135
+ { title: 'Claude', value: 'claude' },
136
+ { title: 'Codex', value: 'codex' },
137
+ { title: 'Aider', value: 'aider' },
138
+ ],
139
+ initial: 0,
140
+ },
141
+ {
142
+ type: 'select', name: 'notifications', message: 'Notifications',
143
+ choices: [
144
+ { title: 'Telegram', value: 'telegram' },
145
+ { title: 'None', value: 'none' },
146
+ ],
147
+ initial: 0,
148
+ },
149
+ { type: 'number', name: 'maxTurns', message: 'Max turns per agent run', initial: 200, min: 1 },
150
+ { type: 'number', name: 'maxBudgetUsd', message: 'Max budget per run in USD (API only)', initial: 5, min: 0 },
151
+ { type: 'confirm', name: 'reviewEnabled', message: 'Enable automated PR review?', initial: true },
152
+ {
153
+ type: (prev) => prev ? 'number' : null,
154
+ name: 'maxRounds', message: 'Max review rounds', initial: 3, min: 1, max: 10,
155
+ },
156
+ { type: 'text', name: 'snapshotName', message: 'Daytona snapshot name', initial: 'openthrottle' },
157
+ ], { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
158
+
159
+ return { ...detected, ...response };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 3. Generate .openthrottle.yml
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function generateConfig(config) {
167
+ const doc = {
168
+ base_branch: config.baseBranch,
169
+ test: config.test || undefined,
170
+ dev: config.dev || undefined,
171
+ format: config.format || undefined,
172
+ lint: config.lint || undefined,
173
+ build: config.build || undefined,
174
+ notifications: config.notifications === 'none' ? undefined : config.notifications,
175
+ agent: config.agent,
176
+ snapshot: config.snapshotName || 'openthrottle',
177
+ post_bootstrap: [config.postBootstrap],
178
+ mcp_servers: {},
179
+ env_files: config.envFiles && Object.keys(config.envFiles).length > 0
180
+ ? config.envFiles
181
+ : undefined,
182
+ limits: {
183
+ max_turns: config.maxTurns ?? 200,
184
+ max_budget_usd: config.maxBudgetUsd ?? 5,
185
+ },
186
+ review: {
187
+ enabled: config.reviewEnabled,
188
+ max_rounds: config.maxRounds ?? 3,
189
+ },
190
+ };
191
+
192
+ // Remove undefined fields
193
+ for (const key of Object.keys(doc)) {
194
+ if (doc[key] === undefined) delete doc[key];
195
+ }
196
+
197
+ const header = [
198
+ '# openthrottle.yml — project config for Open Throttle (Daytona runtime)',
199
+ '# Generated by openthrottle init. Committed to the repo so the',
200
+ '# sandbox knows how to work with this project.',
201
+ '',
202
+ ].join('\n');
203
+
204
+ return header + stringify(doc);
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // 4. Copy wake-sandbox.yml
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function copyWorkflow(config) {
212
+ const src = join(__dirname, 'templates', 'wake-sandbox.yml');
213
+ const destDir = join(cwd, '.github', 'workflows');
214
+ const dest = join(destDir, 'wake-sandbox.yml');
215
+ mkdirSync(destDir, { recursive: true });
216
+
217
+ let content = readFileSync(src, 'utf8');
218
+
219
+ // Inject project-specific secrets into the workflow
220
+ const allKeys = config.envAllKeys || [];
221
+ if (allKeys.length > 0) {
222
+ // Add env: entries for secrets
223
+ const envSecrets = allKeys
224
+ .map(k => ` ${k}: \${{ secrets.${k} }}`)
225
+ .join('\n');
226
+ content = content.replace(
227
+ / # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here/,
228
+ envSecrets
229
+ );
230
+
231
+ // Add --env flags for daytona create
232
+ const envFlags = allKeys
233
+ .map(k => ` --env ${k}=\${${k}} \\`)
234
+ .join('\n');
235
+ content = content.replace(
236
+ / # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here/,
237
+ envFlags
238
+ );
239
+ } else {
240
+ // No project secrets — remove the placeholder comments
241
+ content = content.replace(/ # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here\n/, '');
242
+ content = content.replace(/ # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here\n/, '');
243
+ }
244
+
245
+ writeFileSync(dest, content);
246
+ return dest;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // 5. Create Daytona snapshot from pre-built image
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function setupDaytona(config) {
254
+ const snapshotName = config.snapshotName || 'openthrottle';
255
+ const image = 'knoxgraeme/openthrottle:v1';
256
+
257
+ // Check daytona CLI is available
258
+ try {
259
+ execFileSync('daytona', ['--version'], { stdio: 'pipe' });
260
+ } catch {
261
+ console.log(`\n daytona CLI not found. Install it, then run:`);
262
+ console.log(` daytona snapshot create ${snapshotName} --image ${image} --cpu 2 --memory 4 --disk 10\n`);
263
+ return { snapshotName, skipped: true };
264
+ }
265
+
266
+ // Create snapshot from pre-built image
267
+ try {
268
+ execFileSync('daytona', [
269
+ 'snapshot', 'create', snapshotName,
270
+ '--image', image,
271
+ '--cpu', '2', '--memory', '4', '--disk', '10',
272
+ ], { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' });
273
+ console.log(` Created Daytona snapshot: ${snapshotName}`);
274
+ } catch (err) {
275
+ if (err.stderr?.toString().includes('already exists')) {
276
+ console.log(` Snapshot already exists: ${snapshotName}`);
277
+ } else {
278
+ console.log(` Snapshot creation failed. You can create it manually:`);
279
+ console.log(` daytona snapshot create ${snapshotName} --image ${image} --cpu 2 --memory 4 --disk 10`);
280
+ }
281
+ }
282
+
283
+ return { snapshotName };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // 6. Push .env secrets to GitHub repo secrets
288
+ // ---------------------------------------------------------------------------
289
+
290
+ async function pushSecrets(config) {
291
+ const envFiles = config.envFiles || {};
292
+ const paths = Object.keys(envFiles);
293
+ if (paths.length === 0) return;
294
+
295
+ // Check gh is available and authenticated
296
+ try {
297
+ execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
298
+ } catch {
299
+ console.log('\n gh CLI not authenticated — skipping secret push.');
300
+ console.log(' Run "gh auth login" then set secrets manually.\n');
301
+ return;
302
+ }
303
+
304
+ // Detect repo
305
+ let repo;
306
+ try {
307
+ repo = execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], {
308
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
309
+ }).trim();
310
+ } catch {
311
+ console.log('\n Could not detect GitHub repo — skipping secret push.\n');
312
+ return;
313
+ }
314
+
315
+ // Show what we'd push
316
+ console.log(`\n Push .env secrets to GitHub repo secrets? (${repo})`);
317
+ console.log(' Values are encrypted at rest — not readable after upload.\n');
318
+ for (const [path, keys] of Object.entries(envFiles)) {
319
+ console.log(` ${path} (${keys.length} keys): ${keys.join(', ')}`);
320
+ }
321
+ console.log('');
322
+
323
+ const { confirm } = await prompts({
324
+ type: 'confirm', name: 'confirm',
325
+ message: `Push ${config.envAllKeys.length} secret(s) to ${repo}?`, initial: false,
326
+ }, { onCancel: () => {} });
327
+
328
+ if (!confirm) {
329
+ console.log(' Skipped secret push.');
330
+ return;
331
+ }
332
+
333
+ let pushed = 0;
334
+ let failed = 0;
335
+ for (const [path, keys] of Object.entries(envFiles)) {
336
+ const fullPath = join(cwd, path);
337
+ let content;
338
+ try {
339
+ content = readFileSync(fullPath, 'utf8');
340
+ } catch {
341
+ console.log(` Could not read ${path} — skipping`);
342
+ failed += keys.length;
343
+ continue;
344
+ }
345
+
346
+ // Parse key=value pairs
347
+ for (const line of content.split('\n')) {
348
+ const trimmed = line.trim();
349
+ if (!trimmed || trimmed.startsWith('#')) continue;
350
+ const cleaned = trimmed.replace(/^export\s+/, '');
351
+ const eqIdx = cleaned.indexOf('=');
352
+ if (eqIdx === -1) continue;
353
+ const key = cleaned.slice(0, eqIdx);
354
+ let value = cleaned.slice(eqIdx + 1);
355
+ // Strip surrounding quotes
356
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
357
+ value = value.slice(1, -1);
358
+ }
359
+ if (!keys.includes(key)) continue;
360
+
361
+ try {
362
+ execFileSync('gh', ['secret', 'set', key, '--repo', repo], {
363
+ input: value,
364
+ stdio: ['pipe', 'pipe', 'pipe'],
365
+ });
366
+ pushed++;
367
+ } catch (err) {
368
+ const stderr = err.stderr?.toString().trim() || err.message;
369
+ console.log(` Failed to set ${key}: ${stderr}`);
370
+ failed++;
371
+ }
372
+ }
373
+ }
374
+
375
+ console.log(` Pushed ${pushed} secret(s) to ${repo}${failed > 0 ? ` (${failed} failed)` : ''}`);
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // 7. Print next steps
380
+ // ---------------------------------------------------------------------------
381
+
382
+ function printNextSteps(config) {
383
+ const agentSecret =
384
+ config.agent === 'claude'
385
+ ? ' ANTHROPIC_API_KEY <- option a: pay-per-use API key\n CLAUDE_CODE_OAUTH_TOKEN <- option b: subscription token (claude setup-token)'
386
+ : config.agent === 'codex'
387
+ ? ' OPENAI_API_KEY <- required for Codex'
388
+ : ' OPENAI_API_KEY <- or ANTHROPIC_API_KEY (depends on your Aider model)';
389
+ const secrets = [
390
+ ' DAYTONA_API_KEY <- required',
391
+ agentSecret,
392
+ ];
393
+
394
+ // Project-specific secrets from env_files
395
+ const projectKeys = config.envAllKeys || [];
396
+ const projectSecrets = projectKeys.length > 0
397
+ ? '\n\n Project secrets (from .env files):\n' +
398
+ projectKeys.map(k => ` ${k}`).join('\n')
399
+ : '';
400
+
401
+ console.log(`
402
+ Next steps:
403
+
404
+ 1. Set GitHub repo secrets:
405
+ ${secrets.join('\n')}
406
+ TELEGRAM_BOT_TOKEN <- optional (notifications)
407
+ TELEGRAM_CHAT_ID <- optional (notifications)${projectSecrets}
408
+
409
+ 2. Commit and push:
410
+ git add .openthrottle.yml .github/workflows/wake-sandbox.yml
411
+ git commit -m "feat: add openthrottle config"
412
+ git push
413
+
414
+ 3. Ship your first prompt:
415
+ npx openthrottle ship docs/prds/my-feature.md
416
+ `);
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Main (exported for use by index.mjs)
421
+ // ---------------------------------------------------------------------------
422
+
423
+ export default async function init() {
424
+ console.log('\n openthrottle init\n');
425
+
426
+ // Step 1: Detect
427
+ const detected = detectProject();
428
+ const { envFiles, allKeys: envAllKeys } = detectEnvFiles();
429
+
430
+ if (Object.keys(envFiles).length > 0) {
431
+ console.log(` Found ${Object.keys(envFiles).length} .env file(s):`);
432
+ for (const [path, keys] of Object.entries(envFiles)) {
433
+ console.log(` ${path} (${keys.length} keys)`);
434
+ }
435
+ console.log('');
436
+ }
437
+
438
+ // Step 2: Prompt
439
+ const config = await promptConfig(detected);
440
+ config.envFiles = envFiles;
441
+ config.envAllKeys = envAllKeys;
442
+
443
+ // Step 3: Generate config
444
+ const configPath = join(cwd, '.openthrottle.yml');
445
+ if (existsSync(configPath)) {
446
+ const { overwrite } = await prompts({
447
+ type: 'confirm', name: 'overwrite',
448
+ message: '.openthrottle.yml already exists. Overwrite?', initial: false,
449
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
450
+ if (!overwrite) { console.log(' Skipped .openthrottle.yml'); }
451
+ else { writeFileSync(configPath, generateConfig(config)); console.log(' Generated .openthrottle.yml'); }
452
+ } else {
453
+ writeFileSync(configPath, generateConfig(config));
454
+ console.log(' Generated .openthrottle.yml');
455
+ }
456
+
457
+ // Step 4: Copy workflow
458
+ const workflowPath = join(cwd, '.github', 'workflows', 'wake-sandbox.yml');
459
+ if (existsSync(workflowPath)) {
460
+ const { overwrite } = await prompts({
461
+ type: 'confirm', name: 'overwrite',
462
+ message: 'wake-sandbox.yml already exists. Overwrite?', initial: false,
463
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
464
+ if (!overwrite) { console.log(' Skipped wake-sandbox.yml'); }
465
+ else { copyWorkflow(config); console.log(' Copied .github/workflows/wake-sandbox.yml'); }
466
+ } else {
467
+ copyWorkflow(config);
468
+ console.log(' Copied .github/workflows/wake-sandbox.yml');
469
+ }
470
+
471
+ // Step 5: Create Daytona snapshot
472
+ setupDaytona(config);
473
+
474
+ // Step 6: Push secrets
475
+ await pushSecrets(config);
476
+
477
+ // Step 7: Next steps
478
+ printNextSteps(config);
479
+ }
package/package.json CHANGED
@@ -1,15 +1,32 @@
1
1
  {
2
2
  "name": "openthrottle",
3
- "version": "0.0.1",
4
- "description": "OpenThrottle CLI — coming soon.",
3
+ "version": "0.1.1",
4
+ "description": "CLI for Open Throttle ship prompts to Daytona sandboxes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "openthrottle": "./index.mjs"
8
+ },
9
+ "files": [
10
+ "index.mjs",
11
+ "init.mjs",
12
+ "templates/"
13
+ ],
14
+ "dependencies": {
15
+ "prompts": "^2.4.2",
16
+ "yaml": "^2.4.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
5
21
  "license": "MIT",
6
22
  "repository": {
7
23
  "type": "git",
8
- "url": "https://github.com/knoxgraeme/sodaprompts"
24
+ "url": "https://github.com/knoxgraeme/openthrottle"
9
25
  },
10
26
  "keywords": [
11
27
  "openthrottle",
12
28
  "daytona",
13
- "agent"
29
+ "agent",
30
+ "cli"
14
31
  ]
15
32
  }
@@ -0,0 +1,150 @@
1
+ # Wake Daytona sandbox when work arrives on GitHub.
2
+ #
3
+ # Triggers:
4
+ # - Issue labeled prd-queued or bug-queued → builder sandbox
5
+ # - PR labeled needs-review → reviewer sandbox
6
+ # - PR review with changes_requested → review-fix sandbox
7
+ #
8
+ # Each trigger creates a fresh ephemeral sandbox — no polling, no long-lived state.
9
+ # Multiple triggers fire in parallel (one sandbox per task).
10
+ #
11
+ # Required repository secrets:
12
+ # DAYTONA_API_KEY — API key from daytona.io
13
+ # ANTHROPIC_API_KEY — (option a) or
14
+ # CLAUDE_CODE_OAUTH_TOKEN — (option b) for Claude Code auth
15
+ # TELEGRAM_BOT_TOKEN — optional, for notifications
16
+ # TELEGRAM_CHAT_ID — optional, for notifications
17
+ # SUPABASE_ACCESS_TOKEN — optional, for Supabase MCP
18
+
19
+ name: Wake Sandbox
20
+
21
+ on:
22
+ issues:
23
+ types: [labeled]
24
+ pull_request:
25
+ types: [labeled]
26
+ pull_request_review:
27
+ types: [submitted]
28
+
29
+ concurrency:
30
+ group: openthrottle-${{ github.event.issue.number || github.event.pull_request.number }}
31
+ cancel-in-progress: false
32
+
33
+ permissions:
34
+ contents: read
35
+ issues: write
36
+ pull-requests: write
37
+
38
+ jobs:
39
+ run-task:
40
+ if: >-
41
+ contains(fromJSON('["prd-queued", "bug-queued", "needs-review", "needs-investigation"]'), github.event.label.name) ||
42
+ (github.event.review.state == 'changes_requested')
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+
47
+ - name: Resolve work item
48
+ id: work
49
+ env:
50
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51
+ EVENT_LABEL: ${{ github.event.label.name }}
52
+ EVENT_REVIEW_STATE: ${{ github.event.review.state }}
53
+ EVENT_ISSUE_NUM: ${{ github.event.issue.number }}
54
+ EVENT_PR_NUM: ${{ github.event.pull_request.number }}
55
+ run: |
56
+ # Determine work item number
57
+ WORK_ITEM="${EVENT_ISSUE_NUM}"
58
+ if [[ -z "$WORK_ITEM" ]]; then
59
+ WORK_ITEM="${EVENT_PR_NUM}"
60
+ fi
61
+ if [[ -z "$WORK_ITEM" ]]; then
62
+ echo "::error::Could not determine work item number from event payload"
63
+ exit 1
64
+ fi
65
+ echo "item=$WORK_ITEM" >> "$GITHUB_OUTPUT"
66
+
67
+ # Determine task type (using env vars, not inline ${{ }} expressions)
68
+ TASK_TYPE="prd"
69
+ if [[ "$EVENT_LABEL" == "bug-queued" ]]; then
70
+ TASK_TYPE="bug"
71
+ elif [[ "$EVENT_LABEL" == "needs-review" ]]; then
72
+ TASK_TYPE="review"
73
+ elif [[ "$EVENT_LABEL" == "needs-investigation" ]]; then
74
+ TASK_TYPE="investigation"
75
+ elif [[ "$EVENT_REVIEW_STATE" == "changes_requested" ]]; then
76
+ TASK_TYPE="review-fix"
77
+ fi
78
+ echo "task_type=$TASK_TYPE" >> "$GITHUB_OUTPUT"
79
+
80
+ # Extract session ID for review fixes
81
+ RESUME_SESSION=""
82
+ if [[ "$TASK_TYPE" == "review-fix" ]]; then
83
+ RESUME_SESSION=$(gh pr view "$EVENT_PR_NUM" --json comments \
84
+ --jq '[.comments[].body | capture("session-id: (?<id>[^ ]+)") | .id] | last // empty') || true
85
+ fi
86
+ echo "resume_session=$RESUME_SESSION" >> "$GITHUB_OUTPUT"
87
+
88
+ - name: Validate config
89
+ id: config
90
+ run: |
91
+ SNAPSHOT=$(yq '.snapshot // ""' .openthrottle.yml)
92
+ if [[ -z "$SNAPSHOT" || "$SNAPSHOT" == "null" ]]; then
93
+ echo "::error::Missing 'snapshot' key in .openthrottle.yml — cannot create sandbox"
94
+ exit 1
95
+ fi
96
+ echo "snapshot=$SNAPSHOT" >> "$GITHUB_OUTPUT"
97
+
98
+ - name: Activate snapshot (reactivates if idle >2 weeks)
99
+ env:
100
+ DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
101
+ run: |
102
+ daytona snapshot activate "${{ steps.config.outputs.snapshot }}" 2>/dev/null || true
103
+
104
+ - name: Create and run sandbox
105
+ env:
106
+ DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
107
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
109
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
110
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
111
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
112
+ # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here
113
+ run: |
114
+ # Create ephemeral sandbox
115
+ OUTPUT=$(daytona create \
116
+ --snapshot "${{ steps.config.outputs.snapshot }}" \
117
+ --auto-delete 0 \
118
+ --auto-stop 60 \
119
+ --cpu 2 --memory 4096 --disk 10 \
120
+ --label project=${{ github.event.repository.name }} \
121
+ --label task_type="${{ steps.work.outputs.task_type }}" \
122
+ --label issue="${{ steps.work.outputs.item }}" \
123
+ --volume openthrottle-${{ github.repository_id }}:/home/daytona/.claude \
124
+ --env GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \
125
+ --env GITHUB_REPO=${{ github.repository }} \
126
+ --env ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
127
+ --env CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN} \
128
+ --env OPENAI_API_KEY=${OPENAI_API_KEY} \
129
+ --env SUPABASE_ACCESS_TOKEN=${SUPABASE_ACCESS_TOKEN} \
130
+ --env TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }} \
131
+ --env TELEGRAM_CHAT_ID=${{ secrets.TELEGRAM_CHAT_ID }} \
132
+ --env WORK_ITEM="${{ steps.work.outputs.item }}" \
133
+ --env TASK_TYPE="${{ steps.work.outputs.task_type }}" \
134
+ --env RESUME_SESSION="${{ steps.work.outputs.resume_session }}" \
135
+ # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here
136
+ 2>&1) || {
137
+ # Redact secrets from error output
138
+ SAFE_OUTPUT=$(echo "$OUTPUT" | sed \
139
+ -e "s/${ANTHROPIC_API_KEY:-___}/[REDACTED]/g" \
140
+ -e "s/${CLAUDE_CODE_OAUTH_TOKEN:-___}/[REDACTED]/g" \
141
+ -e "s/${OPENAI_API_KEY:-___}/[REDACTED]/g" \
142
+ -e "s/${SUPABASE_ACCESS_TOKEN:-___}/[REDACTED]/g" \
143
+ -e "s/${GH_TOKEN:-___}/[REDACTED]/g")
144
+ echo "::error::Sandbox creation failed: $SAFE_OUTPUT"
145
+ exit 1
146
+ }
147
+ SANDBOX_ID="$OUTPUT"
148
+
149
+ echo "Sandbox created: $SANDBOX_ID"
150
+ echo "Task: ${{ steps.work.outputs.task_type }} #${{ steps.work.outputs.item }}"