@getjack/jack 0.1.0 → 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/src/lib/agents.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
2
3
  import { homedir } from "node:os";
3
- import { type AgentConfig, readConfig, writeConfig } from "./config.ts";
4
+ import { delimiter, extname, join } from "node:path";
5
+ import { type AgentConfig, type AgentLaunchConfig, readConfig, writeConfig } from "./config.ts";
4
6
 
5
7
  // Re-export AgentConfig for consumers
6
8
  export type { AgentConfig } from "./config.ts";
@@ -20,16 +22,20 @@ export interface ProjectFile {
20
22
  export interface AgentDefinition {
21
23
  id: string;
22
24
  name: string;
23
- searchPaths: string[];
24
25
  projectFiles: ProjectFile[];
25
26
  priority: number; // Lower = higher priority for default selection (claude-code=1, codex=2, etc.)
27
+ launch?: AgentLaunchDefinition;
28
+ }
29
+
30
+ export interface AgentLaunchDefinition {
31
+ cliCommands?: Array<{ command: string; args?: string[] }>;
26
32
  }
27
33
 
28
34
  /**
29
35
  * Result of scanning for agents
30
36
  */
31
37
  export interface DetectionResult {
32
- detected: Array<{ id: string; path: string }>;
38
+ detected: Array<{ id: string; path: string; launch?: AgentLaunchConfig }>;
33
39
  total: number;
34
40
  }
35
41
 
@@ -51,11 +57,9 @@ export const AGENT_REGISTRY: AgentDefinition[] = [
51
57
  id: "claude-code",
52
58
  name: "Claude Code",
53
59
  priority: 1,
54
- searchPaths: [
55
- "~/.claude",
56
- "~/.config/claude",
57
- "%APPDATA%/Claude", // Windows
58
- ],
60
+ launch: {
61
+ cliCommands: [{ command: "claude" }],
62
+ },
59
63
  projectFiles: [
60
64
  { path: "CLAUDE.md", template: "claude-md" },
61
65
  { path: "AGENTS.md", template: "agents-md", shared: true },
@@ -65,41 +69,11 @@ export const AGENT_REGISTRY: AgentDefinition[] = [
65
69
  id: "codex",
66
70
  name: "Codex",
67
71
  priority: 2,
68
- searchPaths: [
69
- "~/.codex",
70
- "%APPDATA%/codex", // Windows
71
- ],
72
+ launch: {
73
+ cliCommands: [{ command: "codex" }],
74
+ },
72
75
  projectFiles: [{ path: "AGENTS.md", template: "agents-md", shared: true }],
73
76
  },
74
- {
75
- id: "cursor",
76
- name: "Cursor",
77
- priority: 10,
78
- searchPaths: [
79
- "/Applications/Cursor.app",
80
- "~/.cursor",
81
- "%PROGRAMFILES%/Cursor", // Windows
82
- "/usr/share/cursor", // Linux
83
- ],
84
- projectFiles: [
85
- { path: ".cursorrules", template: "cursorrules" },
86
- { path: "AGENTS.md", template: "agents-md", shared: true },
87
- ],
88
- },
89
- {
90
- id: "windsurf",
91
- name: "Windsurf",
92
- priority: 10,
93
- searchPaths: [
94
- "/Applications/Windsurf.app",
95
- "~/.windsurf",
96
- "%PROGRAMFILES%/Windsurf", // Windows
97
- ],
98
- projectFiles: [
99
- { path: ".windsurfrules", template: "windsurfrules" },
100
- { path: "AGENTS.md", template: "agents-md", shared: true },
101
- ],
102
- },
103
77
  ];
104
78
 
105
79
  /**
@@ -127,6 +101,72 @@ export function pathExists(path: string): boolean {
127
101
  }
128
102
  }
129
103
 
104
+ function findExecutable(command: string): string | null {
105
+ const expanded = expandPath(command);
106
+ if (expanded.includes("/") || expanded.includes("\\")) {
107
+ return existsSync(expanded) ? expanded : null;
108
+ }
109
+
110
+ const pathEnv = process.env.PATH ?? "";
111
+ const paths = pathEnv.split(delimiter).filter(Boolean);
112
+
113
+ if (process.platform === "win32") {
114
+ const extension = extname(command);
115
+ const extensions =
116
+ extension.length > 0
117
+ ? [""]
118
+ : (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";");
119
+
120
+ for (const basePath of paths) {
121
+ for (const ext of extensions) {
122
+ const candidate = join(basePath, `${command}${ext}`);
123
+ if (existsSync(candidate)) {
124
+ return candidate;
125
+ }
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ for (const basePath of paths) {
132
+ const candidate = join(basePath, command);
133
+ if (existsSync(candidate)) {
134
+ return candidate;
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ function resolveCliLaunch(definition: AgentDefinition): AgentLaunchConfig | null {
142
+ const candidates = definition.launch?.cliCommands ?? [];
143
+ for (const candidate of candidates) {
144
+ const resolved = findExecutable(candidate.command);
145
+ if (resolved) {
146
+ return { type: "cli", command: resolved, args: candidate.args };
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function resolveAgentLaunch(definition: AgentDefinition): AgentLaunchConfig | null {
153
+ return resolveCliLaunch(definition);
154
+ }
155
+
156
+ function normalizeLaunchConfig(launch: AgentLaunchConfig): AgentLaunchConfig | null {
157
+ if (launch.type === "cli") {
158
+ const resolved = findExecutable(launch.command);
159
+ if (!resolved) return null;
160
+ return { type: "cli", command: resolved, args: launch.args };
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function getLaunchPath(launch: AgentLaunchConfig): string | null {
166
+ if (launch.type === "cli") return launch.command;
167
+ return null;
168
+ }
169
+
130
170
  /**
131
171
  * Get agent definition by ID
132
172
  */
@@ -135,17 +175,16 @@ export function getAgentDefinition(id: string): AgentDefinition | undefined {
135
175
  }
136
176
 
137
177
  /**
138
- * Scan for installed agents by checking known paths
178
+ * Scan for installed agents by checking launch commands
139
179
  */
140
180
  export async function scanAgents(): Promise<DetectionResult> {
141
- const detected: Array<{ id: string; path: string }> = [];
181
+ const detected: Array<{ id: string; path: string; launch?: AgentLaunchConfig }> = [];
142
182
 
143
183
  for (const agent of AGENT_REGISTRY) {
144
- for (const searchPath of agent.searchPaths) {
145
- if (pathExists(searchPath)) {
146
- detected.push({ id: agent.id, path: expandPath(searchPath) });
147
- break; // Use first found path
148
- }
184
+ const launch = resolveAgentLaunch(agent);
185
+ const installPath = launch ? getLaunchPath(launch) : null;
186
+ if (launch && installPath && pathExists(installPath)) {
187
+ detected.push({ id: agent.id, path: installPath, launch });
149
188
  }
150
189
  }
151
190
 
@@ -194,25 +233,25 @@ export async function updateAgent(id: string, config: AgentConfig): Promise<void
194
233
  /**
195
234
  * Add agent to config (auto-detect or use custom path)
196
235
  */
197
- export async function addAgent(id: string, path?: string): Promise<void> {
236
+ export async function addAgent(
237
+ id: string,
238
+ options: { launch?: AgentLaunchConfig } = {},
239
+ ): Promise<void> {
198
240
  const definition = getAgentDefinition(id);
199
241
  if (!definition) {
200
242
  throw new Error(`Unknown agent: ${id}`);
201
243
  }
202
244
 
203
- // If no custom path, try to detect
204
- let detectedPath = path;
205
- if (!detectedPath) {
206
- for (const searchPath of definition.searchPaths) {
207
- if (pathExists(searchPath)) {
208
- detectedPath = expandPath(searchPath);
209
- break;
210
- }
211
- }
245
+ const launchOverride = options.launch ? normalizeLaunchConfig(options.launch) : null;
246
+ const detectedLaunch = launchOverride ?? resolveAgentLaunch(definition);
247
+ const detectedPath = detectedLaunch ? getLaunchPath(detectedLaunch) : null;
248
+
249
+ if (!detectedLaunch) {
250
+ throw new Error(`Could not detect ${definition.name}`);
212
251
  }
213
252
 
214
253
  if (!detectedPath) {
215
- throw new Error(`Could not detect ${definition.name}`);
254
+ throw new Error(`Could not determine install path for ${definition.name}`);
216
255
  }
217
256
 
218
257
  if (!pathExists(detectedPath)) {
@@ -221,8 +260,9 @@ export async function addAgent(id: string, path?: string): Promise<void> {
221
260
 
222
261
  await updateAgent(id, {
223
262
  active: true,
224
- path: detectedPath,
263
+ path: expandPath(detectedPath),
225
264
  detectedAt: new Date().toISOString(),
265
+ launch: detectedLaunch,
226
266
  });
227
267
  }
228
268
 
@@ -287,10 +327,12 @@ export async function validateAgentPaths(): Promise<ValidationResult> {
287
327
 
288
328
  for (const [id, agentConfig] of Object.entries(agents)) {
289
329
  if (agentConfig.active) {
290
- if (pathExists(agentConfig.path)) {
291
- valid.push({ id, path: agentConfig.path });
330
+ const launch = agentConfig.launch ? normalizeLaunchConfig(agentConfig.launch) : null;
331
+ const path = launch ? getLaunchPath(launch) : agentConfig.path;
332
+ if (launch && path && pathExists(path)) {
333
+ valid.push({ id, path });
292
334
  } else {
293
- invalid.push({ id, path: agentConfig.path });
335
+ invalid.push({ id, path: path ?? agentConfig.path });
294
336
  }
295
337
  }
296
338
  }
@@ -298,6 +340,138 @@ export async function validateAgentPaths(): Promise<ValidationResult> {
298
340
  return { valid, invalid };
299
341
  }
300
342
 
343
+ function launchConfigsEqual(
344
+ left?: AgentLaunchConfig | null,
345
+ right?: AgentLaunchConfig | null,
346
+ ): boolean {
347
+ if (!left || !right) return left === right;
348
+ return JSON.stringify(left) === JSON.stringify(right);
349
+ }
350
+
351
+ export async function getAgentLaunch(id: string): Promise<AgentLaunchConfig | null> {
352
+ const definition = getAgentDefinition(id);
353
+ if (!definition) return null;
354
+
355
+ const config = await readConfig();
356
+ const agentConfig = config?.agents?.[id];
357
+
358
+ const normalized = agentConfig?.launch ? normalizeLaunchConfig(agentConfig.launch) : null;
359
+ if (normalized) {
360
+ if (agentConfig && config && !launchConfigsEqual(normalized, agentConfig.launch)) {
361
+ agentConfig.launch = normalized;
362
+ await writeConfig(config);
363
+ }
364
+ return normalized;
365
+ }
366
+
367
+ const resolved = resolveAgentLaunch(definition);
368
+ if (resolved && agentConfig && config) {
369
+ if (!launchConfigsEqual(resolved, agentConfig.launch)) {
370
+ agentConfig.launch = resolved;
371
+ await writeConfig(config);
372
+ }
373
+ }
374
+ return resolved ?? null;
375
+ }
376
+
377
+ export async function getPreferredLaunchAgent(): Promise<
378
+ | {
379
+ id: string;
380
+ definition: AgentDefinition;
381
+ launch: AgentLaunchConfig;
382
+ }
383
+ | null
384
+ > {
385
+ const config = await readConfig();
386
+ if (!config?.agents) return null;
387
+
388
+ const activeAgents: Array<{ id: string; config: AgentConfig; definition: AgentDefinition }> = [];
389
+ for (const [id, agentConfig] of Object.entries(config.agents)) {
390
+ if (!agentConfig.active) continue;
391
+ const definition = getAgentDefinition(id);
392
+ if (definition) {
393
+ activeAgents.push({ id, config: agentConfig, definition });
394
+ }
395
+ }
396
+
397
+ if (activeAgents.length === 0) return null;
398
+
399
+ if (config.preferredAgent) {
400
+ const preferred = activeAgents.find((agent) => agent.id === config.preferredAgent);
401
+ if (preferred) {
402
+ const launch = await getAgentLaunch(preferred.id);
403
+ if (launch) {
404
+ return { id: preferred.id, definition: preferred.definition, launch };
405
+ }
406
+ }
407
+ }
408
+
409
+ activeAgents.sort((a, b) => a.definition.priority - b.definition.priority);
410
+ for (const agent of activeAgents) {
411
+ const launch = await getAgentLaunch(agent.id);
412
+ if (launch) {
413
+ return { id: agent.id, definition: agent.definition, launch };
414
+ }
415
+ }
416
+
417
+ return null;
418
+ }
419
+
420
+ function buildLaunchCommand(
421
+ launch: AgentLaunchConfig,
422
+ projectDir: string,
423
+ ):
424
+ | {
425
+ command: string;
426
+ args: string[];
427
+ options: { cwd?: string; stdio: "inherit" | "ignore"; detached?: boolean };
428
+ waitForExit: boolean;
429
+ }
430
+ | null {
431
+ if (launch.type !== "cli") return null;
432
+
433
+ return {
434
+ command: launch.command,
435
+ args: launch.args ?? [],
436
+ options: { cwd: projectDir, stdio: "inherit" },
437
+ waitForExit: true,
438
+ };
439
+ }
440
+
441
+ export async function launchAgent(
442
+ launch: AgentLaunchConfig,
443
+ projectDir: string,
444
+ ): Promise<{ success: boolean; error?: string; command?: string[]; exitCode?: number | null }> {
445
+ const launchCommand = buildLaunchCommand(launch, projectDir);
446
+ if (!launchCommand) {
447
+ return { success: false, error: "No supported launch command found" };
448
+ }
449
+
450
+ const { command, args, options, waitForExit } = launchCommand;
451
+ const displayCommand = [command, ...args];
452
+
453
+ return await new Promise((resolve) => {
454
+ const child = spawn(command, args, options);
455
+
456
+ child.once("error", (err) => {
457
+ resolve({ success: false, error: err.message, command: displayCommand });
458
+ });
459
+
460
+ child.once("spawn", () => {
461
+ if (!waitForExit) {
462
+ child.unref();
463
+ resolve({ success: true, command: displayCommand });
464
+ }
465
+ });
466
+
467
+ if (waitForExit) {
468
+ child.once("exit", (code) => {
469
+ resolve({ success: true, command: displayCommand, exitCode: code });
470
+ });
471
+ }
472
+ });
473
+ }
474
+
301
475
  /**
302
476
  * Get the user's preferred agent ID
303
477
  * Falls back to highest priority detected agent if not set
@@ -355,7 +529,7 @@ export async function setPreferredAgent(id: string): Promise<void> {
355
529
  * Used during jack init to set initial preference
356
530
  */
357
531
  export function getDefaultPreferredAgent(
358
- detected: Array<{ id: string; path: string }>,
532
+ detected: Array<{ id: string; path: string; launch?: AgentLaunchConfig }>,
359
533
  ): string | null {
360
534
  if (detected.length === 0) return null;
361
535
 
@@ -34,10 +34,11 @@ export async function checkWorkerExists(name: string): Promise<boolean> {
34
34
  * Delete a worker with force flag (no confirmation)
35
35
  */
36
36
  export async function deleteWorker(name: string): Promise<void> {
37
- const result = await $`wrangler delete ${name} --force`.nothrow().quiet();
37
+ const result = await $`wrangler delete --name ${name} --force`.nothrow().quiet();
38
38
 
39
39
  if (result.exitCode !== 0) {
40
- throw new Error(`Failed to delete worker ${name}`);
40
+ const stderr = result.stderr.toString().trim();
41
+ throw new Error(stderr || `Failed to delete worker ${name}`);
41
42
  }
42
43
  }
43
44
 
package/src/lib/config.ts CHANGED
@@ -6,10 +6,18 @@ import { join } from "node:path";
6
6
  /**
7
7
  * Agent configuration stored in jack config
8
8
  */
9
+ export type AgentLaunchConfig =
10
+ | {
11
+ type: "cli";
12
+ command: string;
13
+ args?: string[];
14
+ };
15
+
9
16
  export interface AgentConfig {
10
17
  active: boolean;
11
18
  path: string;
12
19
  detectedAt: string;
20
+ launch?: AgentLaunchConfig;
13
21
  }
14
22
 
15
23
  /**
@@ -0,0 +1,53 @@
1
+ export enum JackErrorCode {
2
+ AUTH_FAILED = "AUTH_FAILED",
3
+ WRANGLER_AUTH_EXPIRED = "WRANGLER_AUTH_EXPIRED",
4
+ PROJECT_NOT_FOUND = "PROJECT_NOT_FOUND",
5
+ TEMPLATE_NOT_FOUND = "TEMPLATE_NOT_FOUND",
6
+ BUILD_FAILED = "BUILD_FAILED",
7
+ DEPLOY_FAILED = "DEPLOY_FAILED",
8
+ VALIDATION_ERROR = "VALIDATION_ERROR",
9
+ INTERNAL_ERROR = "INTERNAL_ERROR",
10
+ }
11
+
12
+ export interface JackErrorMeta {
13
+ exitCode?: number;
14
+ missingSecrets?: string[];
15
+ stderr?: string;
16
+ reported?: boolean;
17
+ }
18
+
19
+ export class JackError extends Error {
20
+ code: JackErrorCode;
21
+ suggestion?: string;
22
+ meta?: JackErrorMeta;
23
+
24
+ constructor(code: JackErrorCode, message: string, suggestion?: string, meta?: JackErrorMeta) {
25
+ super(message);
26
+ this.name = "JackError";
27
+ this.code = code;
28
+ this.suggestion = suggestion;
29
+ this.meta = meta;
30
+ }
31
+ }
32
+
33
+ export function isJackError(error: unknown): error is JackError {
34
+ return error instanceof JackError;
35
+ }
36
+
37
+ export function getErrorDetails(error: unknown): {
38
+ message: string;
39
+ suggestion?: string;
40
+ meta?: JackErrorMeta;
41
+ code?: JackErrorCode;
42
+ } {
43
+ if (isJackError(error)) {
44
+ return {
45
+ message: error.message,
46
+ suggestion: error.suggestion,
47
+ meta: error.meta,
48
+ code: error.code,
49
+ };
50
+ }
51
+
52
+ return { message: error instanceof Error ? error.message : String(error) };
53
+ }
package/src/lib/hooks.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { HookAction } from "../templates/types";
4
- import { output } from "./output";
5
4
  import { getSavedSecrets } from "./secrets";
6
5
 
7
6
  export interface HookContext {
@@ -11,6 +10,27 @@ export interface HookContext {
11
10
  projectDir?: string; // absolute path to project directory
12
11
  }
13
12
 
13
+ export interface HookOutput {
14
+ info(message: string): void;
15
+ warn(message: string): void;
16
+ error(message: string): void;
17
+ success(message: string): void;
18
+ box(title: string, lines: string[]): void;
19
+ }
20
+
21
+ export interface HookOptions {
22
+ interactive?: boolean;
23
+ output?: HookOutput;
24
+ }
25
+
26
+ const noopOutput: HookOutput = {
27
+ info() {},
28
+ warn() {},
29
+ error() {},
30
+ success() {},
31
+ box() {},
32
+ };
33
+
14
34
  /**
15
35
  * Prompt user with numbered options (Claude Code style)
16
36
  * Returns the selected option index (0-based) or -1 if cancelled
@@ -197,99 +217,127 @@ async function checkEnvExists(env: string, projectDir?: string): Promise<boolean
197
217
  * Execute a single hook action
198
218
  * Returns true if should continue, false if should abort
199
219
  */
200
- async function executeAction(action: HookAction, context: HookContext): Promise<boolean> {
220
+ async function executeAction(
221
+ action: HookAction,
222
+ context: HookContext,
223
+ options?: HookOptions,
224
+ ): Promise<boolean> {
225
+ const interactive = options?.interactive !== false;
226
+ const ui = options?.output ?? noopOutput;
227
+
201
228
  switch (action.action) {
202
229
  case "message": {
203
- output.info(substituteVars(action.text, context));
230
+ ui.info(substituteVars(action.text, context));
204
231
  return true;
205
232
  }
206
233
 
207
234
  case "box": {
208
235
  const title = substituteVars(action.title, context);
209
236
  const lines = action.lines.map((line) => substituteVars(line, context));
210
- output.box(title, lines);
237
+ ui.box(title, lines);
211
238
  return true;
212
239
  }
213
240
 
214
- case "link": {
241
+ case "url": {
215
242
  const url = substituteVars(action.url, context);
216
243
  const label = action.label ?? "Link";
244
+ if (!interactive) {
245
+ ui.info(`${label}: ${url}`);
246
+ return true;
247
+ }
217
248
  console.error("");
218
249
  console.error(` ${label}: \x1b[36m${url}\x1b[0m`);
219
250
 
251
+ if (action.open) {
252
+ ui.info(`Opening: ${url}`);
253
+ await openBrowser(url);
254
+ return true;
255
+ }
256
+
220
257
  if (action.prompt !== false) {
221
258
  console.error("");
222
259
  const choice = await promptSelect(["Open in browser", "Skip"]);
223
260
  if (choice === 0) {
224
261
  await openBrowser(url);
225
- output.success("Opened in browser");
262
+ ui.success("Opened in browser");
226
263
  }
227
264
  }
228
265
  return true;
229
266
  }
230
267
 
231
- case "open": {
232
- const url = substituteVars(action.url, context);
233
- output.info(`Opening: ${url}`);
234
- await openBrowser(url);
235
- return true;
236
- }
237
-
238
- case "checkSecret": {
239
- const result = await checkSecretExists(action.secret, context.projectDir);
240
- if (!result.exists) {
241
- const message = action.message ?? `Missing required secret: ${action.secret}`;
242
- output.error(message);
243
- output.info(`Run: jack secrets add ${action.secret}`);
244
-
245
- // Offer to open setup URL if provided
246
- if (action.setupUrl) {
247
- console.error("");
248
- const choice = await promptSelect(["Open setup page", "Skip"]);
249
- if (choice === 0) {
250
- await openBrowser(action.setupUrl);
268
+ case "require": {
269
+ if (action.source === "secret") {
270
+ const result = await checkSecretExists(action.key, context.projectDir);
271
+ if (!result.exists) {
272
+ const message = action.message ?? `Missing required secret: ${action.key}`;
273
+ ui.error(message);
274
+ ui.info(`Run: jack secrets add ${action.key}`);
275
+
276
+ if (action.setupUrl) {
277
+ if (interactive) {
278
+ console.error("");
279
+ const choice = await promptSelect(["Open setup page", "Skip"]);
280
+ if (choice === 0) {
281
+ await openBrowser(action.setupUrl);
282
+ }
283
+ } else {
284
+ ui.info(`Setup: ${action.setupUrl}`);
285
+ }
251
286
  }
287
+ return false;
252
288
  }
253
- return false;
289
+ return true;
254
290
  }
255
- return true;
256
- }
257
291
 
258
- case "checkEnv": {
259
- const exists = await checkEnvExists(action.env, context.projectDir);
292
+ const exists = await checkEnvExists(action.key, context.projectDir);
260
293
  if (!exists) {
261
- const message = action.message ?? `Missing required env var: ${action.env}`;
262
- output.error(message);
294
+ const message = action.message ?? `Missing required env var: ${action.key}`;
295
+ ui.error(message);
296
+ if (action.setupUrl) {
297
+ ui.info(`Setup: ${action.setupUrl}`);
298
+ }
263
299
  return false;
264
300
  }
265
301
  return true;
266
302
  }
267
303
 
268
- case "copy": {
304
+ case "clipboard": {
269
305
  const text = substituteVars(action.text, context);
306
+ if (!interactive) {
307
+ ui.info(text);
308
+ return true;
309
+ }
270
310
  const success = await copyToClipboard(text);
271
311
  if (success) {
272
312
  const message = action.message ?? "Copied to clipboard";
273
- output.success(message);
313
+ ui.success(message);
274
314
  } else {
275
- output.warn("Could not copy to clipboard");
315
+ ui.warn("Could not copy to clipboard");
276
316
  }
277
317
  return true;
278
318
  }
279
319
 
280
- case "wait": {
320
+ case "pause": {
321
+ if (!interactive) {
322
+ return true;
323
+ }
281
324
  await waitForEnter(action.message);
282
325
  return true;
283
326
  }
284
327
 
285
- case "run": {
328
+ case "shell": {
286
329
  const command = substituteVars(action.command, context);
287
330
  if (action.message) {
288
- output.info(action.message);
331
+ ui.info(action.message);
289
332
  }
290
333
  const cwd = action.cwd === "project" ? context.projectDir : undefined;
334
+ // Resume stdin in case previous prompts paused it
335
+ if (interactive) {
336
+ process.stdin.resume();
337
+ }
291
338
  const proc = Bun.spawn(["sh", "-c", command], {
292
339
  cwd,
340
+ stdin: interactive ? "inherit" : "ignore",
293
341
  stdout: "inherit",
294
342
  stderr: "inherit",
295
343
  });
@@ -306,9 +354,13 @@ async function executeAction(action: HookAction, context: HookContext): Promise<
306
354
  * Run a list of hook actions
307
355
  * Returns true if all succeeded, false if any failed (for preDeploy checks)
308
356
  */
309
- export async function runHook(actions: HookAction[], context: HookContext): Promise<boolean> {
357
+ export async function runHook(
358
+ actions: HookAction[],
359
+ context: HookContext,
360
+ options?: HookOptions,
361
+ ): Promise<boolean> {
310
362
  for (const action of actions) {
311
- const shouldContinue = await executeAction(action, context);
363
+ const shouldContinue = await executeAction(action, context, options);
312
364
  if (!shouldContinue) {
313
365
  return false;
314
366
  }