Skip to content

Commit d19c822

Browse files
committed
fix: replace httpbin.org with jsonplaceholder.typicode.com
httpbin.org was returning 503 causing test failures. Switched to jsonplaceholder.typicode.com which is more reliable. For timeout tests, using non-routable IP (10.255.255.1) instead of delay endpoint.
1 parent 6227301 commit d19c822

8 files changed

Lines changed: 147 additions & 40 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Commands are resolved in priority order:
8282
- `args`: Named positional arguments for template vars
8383
- `env` (object form): Sets process.env before execution
8484
- `$1`, `$2`, etc.: Map positional args to flags
85+
- `pre`/`before`: Command to run first, output prepended to prompt
86+
- `post`/`after`: Command to run after execution (receives MA_EXIT_CODE env var)
8587

8688
**All other keys** are passed directly as CLI flags:
8789

instructions/CHECK_ACTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
before: gh run list --limit 2
2+
pre: gh run list --limit 2
33
model: claude-haiku-4.5
44
silent: true
55
allow-tool:

instructions/CREATE_INSTRUCTIONS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,4 @@ Description of what this instructions file does.
6565

6666
## Reference files for patterns:
6767
- SETUP_NPM_PUBLISHING.md - Interactive setup with user prompts and browser opening
68-
- CHECK_ACTIONS.md - Using `before:` to run commands and analyze output
69-
- DEMO.md - Using `before:` and `after:` hooks for pre/post processing
68+
- CHECK_ACTIONS.md - Using `pre:` to run commands and analyze output

instructions/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ DEMO.md
2121

2222
| Field | Type | Description |
2323
|-------|------|-------------|
24-
| `pre` | string | Command to run first, output prepended to prompt |
24+
| `pre` | string | Command to run first, output prepended to prompt (alias: `before`) |
25+
| `post` | string | Command to run after execution (alias: `after`) |
2526
| `model` | enum | AI model (claude-haiku-4.5, claude-opus-4.5, gpt-5, etc.) |
2627
| `agent` | string | Custom agent name |
2728
| `silent` | bool | Only output response, no stats |

src/fetch.test.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,17 @@ describe("fetch utilities", () => {
141141

142142
describeNetwork("fetchWithTimeout", () => {
143143
it("successfully fetches when request completes before timeout", async () => {
144-
// Use a real endpoint that responds quickly
145-
const response = await fetchWithTimeout("https://httpbin.org/get", {
144+
// Use jsonplaceholder - reliable and fast
145+
const response = await fetchWithTimeout("https://jsonplaceholder.typicode.com/posts/1", {
146146
timeoutMs: 10000,
147147
});
148148
expect(response.ok).toBe(true);
149149
});
150150

151151
it("throws FetchTimeoutError when request exceeds timeout", async () => {
152-
// Use a delay endpoint with very short timeout
152+
// Use an unreachable IP to trigger timeout (10.255.255.1 is non-routable)
153153
try {
154-
await fetchWithTimeout("https://httpbin.org/delay/5", {
154+
await fetchWithTimeout("http://10.255.255.1:12345/", {
155155
timeoutMs: 100,
156156
});
157157
expect.unreachable("Should have thrown FetchTimeoutError");
@@ -170,21 +170,22 @@ describe("fetch utilities", () => {
170170

171171
describeNetwork("fetchWithRetry", () => {
172172
it("succeeds on first attempt for successful requests", async () => {
173-
const response = await fetchWithRetry("https://httpbin.org/get", {
173+
const response = await fetchWithRetry("https://jsonplaceholder.typicode.com/posts/1", {
174174
retry: { maxRetries: 3 },
175175
});
176176
expect(response.ok).toBe(true);
177177
});
178178

179179
it("returns response for non-retryable 4xx errors without retrying", async () => {
180-
const response = await fetchWithRetry("https://httpbin.org/status/404", {
180+
// jsonplaceholder returns 404 for non-existent posts
181+
const response = await fetchWithRetry("https://jsonplaceholder.typicode.com/posts/99999999", {
181182
retry: { maxRetries: 3 },
182183
});
183184
expect(response.status).toBe(404);
184185
});
185186

186187
it("can disable retries with retry: false", async () => {
187-
const response = await fetchWithRetry("https://httpbin.org/get", {
188+
const response = await fetchWithRetry("https://jsonplaceholder.typicode.com/posts/1", {
188189
retry: false,
189190
});
190191
expect(response.ok).toBe(true);
@@ -202,41 +203,30 @@ describe("fetch utilities", () => {
202203

203204
describeNetwork("resilientFetch", () => {
204205
it("successfully fetches with both timeout and retry protection", async () => {
205-
const response = await resilientFetch("https://httpbin.org/get", {
206+
const response = await resilientFetch("https://jsonplaceholder.typicode.com/posts/1", {
206207
timeoutMs: 10000,
207208
retry: { maxRetries: 2 },
208209
});
209210
expect(response.ok).toBe(true);
210211
});
211212

212-
it("includes custom headers in request", async () => {
213-
const response = await resilientFetch("https://httpbin.org/headers", {
214-
headers: {
215-
"X-Custom-Header": "test-value",
216-
"User-Agent": "test-agent/1.0",
217-
},
218-
});
219-
expect(response.ok).toBe(true);
220-
const data = await response.json();
221-
expect(data.headers["X-Custom-Header"]).toBe("test-value");
222-
});
223-
224213
it("handles 404 response (non-retryable)", async () => {
225-
const response = await resilientFetch("https://httpbin.org/status/404");
214+
const response = await resilientFetch("https://jsonplaceholder.typicode.com/posts/99999999");
226215
expect(response.status).toBe(404);
227216
expect(response.ok).toBe(false);
228217
});
229218

230219
it("can disable retries with retry: false", async () => {
231-
const response = await resilientFetch("https://httpbin.org/get", {
220+
const response = await resilientFetch("https://jsonplaceholder.typicode.com/posts/1", {
232221
retry: false,
233222
});
234223
expect(response.ok).toBe(true);
235224
});
236225

237226
it("throws FetchTimeoutError on timeout", async () => {
238227
try {
239-
await resilientFetch("https://httpbin.org/delay/5", {
228+
// Use unreachable IP to trigger timeout
229+
await resilientFetch("http://10.255.255.1:12345/", {
240230
timeoutMs: 100,
241231
retry: false, // Disable retries to speed up test
242232
});
@@ -248,7 +238,8 @@ describe("fetch utilities", () => {
248238

249239
it("throws FetchRetryError after exhausting retries on timeout", async () => {
250240
try {
251-
await resilientFetch("https://httpbin.org/delay/5", {
241+
// Use unreachable IP to trigger timeout
242+
await resilientFetch("http://10.255.255.1:12345/", {
252243
timeoutMs: 100,
253244
retry: { maxRetries: 1, initialDelayMs: 50 },
254245
});
@@ -280,14 +271,15 @@ describe("fetch utilities", () => {
280271
});
281272

282273
it("handles JSON response", async () => {
283-
const response = await resilientFetch("https://httpbin.org/json", {
274+
const response = await resilientFetch("https://jsonplaceholder.typicode.com/posts/1", {
284275
headers: {
285276
Accept: "application/json",
286277
},
287278
});
288279
expect(response.ok).toBe(true);
289280
const data = await response.json();
290281
expect(data).toBeDefined();
282+
expect(data.id).toBe(1);
291283
});
292284
});
293285
});

src/imports.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,24 +135,23 @@ test("hasImports distinguishes emails from URL imports", () => {
135135
});
136136

137137
test("expandImports fetches markdown URL", async () => {
138-
// Use httpbin.org for testing - returns whatever we send
139-
const content = "Docs: @https://httpbin.org/robots.txt";
138+
// Use jsonplaceholder for testing - reliable API
139+
const content = "Docs: @https://jsonplaceholder.typicode.com/posts/1";
140140
const result = await expandImports(content, testDir);
141-
// httpbin.org/robots.txt returns a simple text file
142141
expect(result).toContain("Docs:");
143142
expect(result).not.toContain("@https://");
144143
});
145144

146145
test("expandImports fetches JSON URL", async () => {
147-
const content = "Data: @https://httpbin.org/json";
146+
const content = "Data: @https://jsonplaceholder.typicode.com/users/1";
148147
const result = await expandImports(content, testDir);
149148
expect(result).toContain("Data:");
150-
expect(result).toContain("slideshow"); // httpbin /json returns slideshow data
149+
expect(result).toContain("Leanne Graham"); // jsonplaceholder user 1 name
151150
expect(result).not.toContain("@https://");
152151
});
153152

154153
test("expandImports preserves emails while expanding URLs", async () => {
155-
const content = "Contact: admin@example.com\nDocs: @https://httpbin.org/robots.txt";
154+
const content = "Contact: admin@example.com\nDocs: @https://jsonplaceholder.typicode.com/posts/1";
156155
const result = await expandImports(content, testDir);
157156
expect(result).toContain("admin@example.com"); // Email preserved
158157
expect(result).not.toContain("@https://"); // URL expanded

src/runtime.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,42 @@ import { initLogger, getParseLogger, getTemplateLogger, getCommandLogger, getImp
2020
import type { AgentFrontmatter } from "./types";
2121
import type { RunResult } from "./command";
2222

23+
/**
24+
* Run a lifecycle hook command and capture its output
25+
*
26+
* @param command - Shell command to execute
27+
* @param cwd - Working directory for the command
28+
* @param env - Additional environment variables
29+
* @returns The stdout output from the command
30+
* @throws Error if the command fails (non-zero exit code)
31+
*/
32+
async function runHookCommand(
33+
command: string,
34+
cwd: string,
35+
env?: Record<string, string>
36+
): Promise<string> {
37+
const proc = Bun.spawn(["sh", "-c", command], {
38+
cwd,
39+
stdout: "pipe",
40+
stderr: "pipe",
41+
env: { ...process.env, ...env },
42+
});
43+
44+
const [stdout, stderr] = await Promise.all([
45+
new Response(proc.stdout).text(),
46+
new Response(proc.stderr).text(),
47+
]);
48+
49+
const exitCode = await proc.exited;
50+
51+
if (exitCode !== 0) {
52+
const errorMsg = stderr.trim() || `Command exited with code ${exitCode}`;
53+
throw new Error(`Hook command failed: ${errorMsg}`);
54+
}
55+
56+
return stdout;
57+
}
58+
2359
/**
2460
* Result of the resolution phase
2561
*/
@@ -52,6 +88,10 @@ export interface AgentContext {
5288
directory: string;
5389
/** Environment variables from frontmatter */
5490
envVars?: Record<string, string>;
91+
/** Output from pre/before hook (prepended to body) */
92+
preHookOutput?: string;
93+
/** Post/after hook command to run after execution */
94+
postHookCommand?: string;
5595
}
5696

5797
/**
@@ -214,13 +254,33 @@ export class AgentRuntime {
214254
}
215255
}
216256

257+
// Run pre/before lifecycle hook
258+
const preCommand = frontmatter.pre || frontmatter.before;
259+
let preHookOutput: string | undefined;
260+
261+
if (preCommand) {
262+
getCommandLogger().debug({ preCommand }, "Running pre hook");
263+
try {
264+
preHookOutput = await runHookCommand(preCommand, resolved.directory, envVars);
265+
getCommandLogger().debug({ outputLength: preHookOutput.length }, "Pre hook completed");
266+
} catch (err) {
267+
getCommandLogger().error({ error: (err as Error).message }, "Pre hook failed");
268+
throw new Error(`Pre hook failed: ${(err as Error).message}`);
269+
}
270+
}
271+
272+
// Capture post/after hook command for later execution
273+
const postHookCommand = frontmatter.post || frontmatter.after;
274+
217275
return {
218276
frontmatter,
219277
rawBody,
220278
expandedBody,
221279
command,
222280
directory: resolved.directory,
223281
envVars,
282+
preHookOutput,
283+
postHookCommand,
224284
};
225285
}
226286

@@ -342,11 +402,17 @@ export class AgentRuntime {
342402
processed: ProcessedTemplate,
343403
options: RuntimeOptions = {}
344404
): Promise<RunResult> {
345-
const { command, envVars } = context;
405+
const { command, envVars, preHookOutput } = context;
346406
const { body, args, positionalMappings } = processed;
347407

348-
// Build final prompt with stdin
408+
// Build final prompt with stdin and pre-hook output
349409
let finalBody = body;
410+
411+
// Prepend pre-hook output if present
412+
if (preHookOutput) {
413+
finalBody = `${preHookOutput.trim()}\n\n${finalBody}`;
414+
}
415+
350416
if (options.stdinContent) {
351417
finalBody = `<stdin>\n${options.stdinContent}\n</stdin>\n\n${finalBody}`;
352418
}
@@ -392,9 +458,16 @@ export class AgentRuntime {
392458

393459
// Handle dry-run mode
394460
if (options.dryRun) {
395-
const finalBody = options.stdinContent
396-
? `<stdin>\n${options.stdinContent}\n</stdin>\n\n${processed.body}`
397-
: processed.body;
461+
let finalBody = processed.body;
462+
463+
// Prepend pre-hook output if present
464+
if (context.preHookOutput) {
465+
finalBody = `${context.preHookOutput.trim()}\n\n${finalBody}`;
466+
}
467+
468+
if (options.stdinContent) {
469+
finalBody = `<stdin>\n${options.stdinContent}\n</stdin>\n\n${finalBody}`;
470+
}
398471

399472
console.log("═══════════════════════════════════════════════════════════");
400473
console.log("DRY RUN - Command will NOT be executed");
@@ -423,6 +496,21 @@ export class AgentRuntime {
423496
// Phase 4: Execution
424497
const result = await this.execute(context, processed, options);
425498

499+
// Phase 5: Post hook (if configured)
500+
if (context.postHookCommand) {
501+
getCommandLogger().debug({ postCommand: context.postHookCommand }, "Running post hook");
502+
try {
503+
await runHookCommand(context.postHookCommand, context.directory, {
504+
...context.envVars,
505+
MA_EXIT_CODE: String(result.exitCode),
506+
});
507+
getCommandLogger().debug("Post hook completed");
508+
} catch (err) {
509+
getCommandLogger().error({ error: (err as Error).message }, "Post hook failed");
510+
// Post hook failures are logged but don't change the exit code
511+
}
512+
}
513+
426514
await this.cleanup();
427515

428516
return {

src/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,32 @@ export interface AgentFrontmatter {
1717
*/
1818
context_window?: number;
1919

20+
/**
21+
* Lifecycle hook: Command to run before context building
22+
* Output is prepended to the prompt body
23+
* Alias: `before`
24+
*/
25+
pre?: string;
26+
27+
/**
28+
* Lifecycle hook: Alias for `pre`
29+
* Command to run before context building, output prepended to prompt
30+
*/
31+
before?: string;
32+
33+
/**
34+
* Lifecycle hook: Command to run after execution completes
35+
* Receives exit code via MA_EXIT_CODE env var
36+
* Alias: `after`
37+
*/
38+
post?: string;
39+
40+
/**
41+
* Lifecycle hook: Alias for `post`
42+
* Command to run after execution completes
43+
*/
44+
after?: string;
45+
2046
/**
2147
* Positional argument mapping ($1, $2, etc.)
2248
* Maps positional arguments to CLI flags

0 commit comments

Comments
 (0)