@boxxy/cli 0.1.0 → 0.1.3

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.
Files changed (66) hide show
  1. package/README.md +35 -4
  2. package/dist/cli/build-answer-result.d.ts +4 -0
  3. package/dist/cli/format-help.d.ts +2 -0
  4. package/dist/cli/make-answer-run.d.ts +2 -0
  5. package/dist/cli/parse-command-args.d.ts +2 -0
  6. package/dist/cli/pick-command.d.ts +13 -0
  7. package/dist/cli/run-cli.d.ts +2 -0
  8. package/dist/cli/usage-error.d.ts +3 -0
  9. package/dist/commands/all-commands.d.ts +2 -0
  10. package/dist/commands/attempt-commands.d.ts +2 -0
  11. package/dist/commands/describe-close-out-problem.d.ts +2 -0
  12. package/dist/commands/hook-commands.d.ts +2 -0
  13. package/dist/commands/item-commands.d.ts +2 -0
  14. package/dist/commands/parse-criterion-flag.d.ts +4 -0
  15. package/dist/commands/plan-commands.d.ts +2 -0
  16. package/dist/commands/relation-commands.d.ts +2 -0
  17. package/dist/commands/report-commands.d.ts +2 -0
  18. package/dist/commands/runner-commands.d.ts +2 -0
  19. package/dist/constants.d.ts +17 -0
  20. package/dist/create-client.d.ts +3 -0
  21. package/dist/hooks/install-skills.d.ts +14 -0
  22. package/dist/hooks/parse-hook-payload.d.ts +16 -0
  23. package/dist/hooks/pick-pull-request-url.d.ts +2 -0
  24. package/dist/hooks/run-post-tool-use-hook.d.ts +2 -0
  25. package/dist/hooks/run-session-start-hook.d.ts +2 -0
  26. package/dist/hooks/wait-for-attempt.d.ts +6 -0
  27. package/dist/index.d.ts +3 -1
  28. package/dist/main.d.ts +2 -0
  29. package/dist/run-git.d.ts +2 -0
  30. package/dist/test-utils/build-mock-cli-deps.d.ts +2 -0
  31. package/dist/test-utils/create-skills-repo.d.ts +10 -0
  32. package/dist/types.d.ts +42 -0
  33. package/hook-plugin/.claude-plugin/plugin.json +4 -0
  34. package/hook-plugin/hooks/hooks.json +16 -0
  35. package/package.json +15 -2
  36. package/src/cli/build-answer-result.ts +24 -0
  37. package/src/cli/format-help.ts +19 -0
  38. package/src/cli/make-answer-run.ts +11 -0
  39. package/src/cli/parse-command-args.ts +91 -0
  40. package/src/cli/pick-command.ts +44 -0
  41. package/src/cli/run-cli.ts +46 -0
  42. package/src/cli/usage-error.ts +4 -0
  43. package/src/commands/all-commands.ts +19 -0
  44. package/src/commands/attempt-commands.ts +132 -0
  45. package/src/commands/describe-close-out-problem.ts +33 -0
  46. package/src/commands/hook-commands.ts +21 -0
  47. package/src/commands/item-commands.ts +140 -0
  48. package/src/commands/parse-criterion-flag.ts +13 -0
  49. package/src/commands/plan-commands.ts +49 -0
  50. package/src/commands/relation-commands.ts +31 -0
  51. package/src/commands/report-commands.ts +23 -0
  52. package/src/commands/runner-commands.ts +45 -0
  53. package/src/constants.ts +19 -0
  54. package/src/create-client.ts +50 -0
  55. package/src/hooks/install-skills.ts +116 -0
  56. package/src/hooks/parse-hook-payload.ts +48 -0
  57. package/src/hooks/pick-pull-request-url.ts +21 -0
  58. package/src/hooks/run-post-tool-use-hook.ts +46 -0
  59. package/src/hooks/run-session-start-hook.ts +55 -0
  60. package/src/hooks/wait-for-attempt.ts +29 -0
  61. package/src/index.ts +3 -1
  62. package/src/main.ts +21 -0
  63. package/src/run-git.ts +22 -0
  64. package/src/types.ts +59 -0
  65. package/dist/package-name.d.ts +0 -1
  66. package/src/package-name.ts +0 -1
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { UsageError } from '../cli/usage-error.ts';
3
+
4
+ // The fields of the JSON that Claude Code writes to a hook's stdin, as far as boxxy reads them.
5
+ // The other fields are dropped.
6
+ export const sessionStartPayloadSchema = z.object({
7
+ session_id: z.string().min(1),
8
+ cwd: z.string().min(1),
9
+ });
10
+
11
+ // the Bash tool answers with an object that holds `stdout`
12
+ const bashResponseSchema = z.object({ stdout: z.string().optional() });
13
+ const toolResponseSchema = z.union([z.string(), bashResponseSchema]);
14
+ const toolInputSchema = z.object({ command: z.string().optional() });
15
+
16
+ export const postToolUsePayloadSchema = z.object({
17
+ tool_name: z.string(),
18
+ tool_input: toolInputSchema.optional(),
19
+ tool_response: toolResponseSchema.optional(),
20
+ });
21
+
22
+ export type PostToolUsePayload = z.infer<typeof postToolUsePayloadSchema>;
23
+
24
+ export function parseHookPayload<Schema extends z.ZodType>(
25
+ schema: Schema,
26
+ text: string,
27
+ ): z.output<Schema> {
28
+ const body = parseJSON(text);
29
+ const parsed = schema.safeParse(body);
30
+
31
+ if (!parsed.success) {
32
+ const problem = parsed.error.message;
33
+
34
+ throw new UsageError(`the hook payload on stdin is not the one expected: ${problem}`);
35
+ }
36
+
37
+ return parsed.data;
38
+ }
39
+
40
+ function parseJSON(text: string): unknown {
41
+ try {
42
+ const payload: unknown = JSON.parse(text);
43
+
44
+ return payload;
45
+ } catch {
46
+ throw new UsageError('the hook payload on stdin is not JSON');
47
+ }
48
+ }
@@ -0,0 +1,21 @@
1
+ import type { PostToolUsePayload } from './parse-hook-payload.ts';
2
+
3
+ const createCommandPattern = /\bgh\s+pr\s+create\b/u;
4
+ const pullRequestURLPattern = /https:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/gu;
5
+
6
+ // The pull request that a `gh pr create` call printed, or null for any other tool call. `gh`
7
+ // prints the new pull request's URL last.
8
+ export function pickPullRequestURL(payload: PostToolUsePayload): string | null {
9
+ if (payload.tool_name !== 'Bash') {
10
+ return null;
11
+ }
12
+
13
+ if (!createCommandPattern.test(payload.tool_input?.command ?? '')) {
14
+ return null;
15
+ }
16
+
17
+ const response = payload.tool_response;
18
+ const stdout = typeof response === 'string' ? response : (response?.stdout ?? '');
19
+
20
+ return stdout.match(pullRequestURLPattern)?.at(-1) ?? null;
21
+ }
@@ -0,0 +1,46 @@
1
+ import { match } from 'ts-pattern';
2
+ import { buildErrorResult, buildTextResult } from '../cli/build-answer-result.ts';
3
+ import type { CLIResult, CommandCall } from '../types.ts';
4
+ import { parseHookPayload, postToolUsePayloadSchema } from './parse-hook-payload.ts';
5
+ import { pickPullRequestURL } from './pick-pull-request-url.ts';
6
+ import { waitForAttempt } from './wait-for-attempt.ts';
7
+
8
+ // the exit code that makes Claude Code show stderr to the agent after a tool call
9
+ const tellAgent = 2;
10
+
11
+ // `boxxy hook post-tool-use`. It runs after every Bash call and does nothing unless the call was
12
+ // `gh pr create` and printed a pull request URL. It then links that pull request to the attempt.
13
+ export async function runPostToolUseHook(call: CommandCall): Promise<CLIResult> {
14
+ const text = await call.deps.readStdin();
15
+
16
+ const payload = parseHookPayload(postToolUsePayloadSchema, text);
17
+ const url = pickPullRequestURL(payload);
18
+
19
+ if (url === null) {
20
+ return buildTextResult('');
21
+ }
22
+
23
+ const current = await waitForAttempt(call.client, call.deps);
24
+
25
+ if (current === null) {
26
+ return buildErrorResult('boxxy has no attempt for this session');
27
+ }
28
+
29
+ const answer = await call.client.attempt.recordPullRequest({
30
+ session: { attempt: current.attempt.id },
31
+ url,
32
+ });
33
+
34
+ return match(answer)
35
+ .with({ kind: 'linked' }, (linked) =>
36
+ buildTextResult(`boxxy linked ${linked.repo}#${linked.number} to this attempt`),
37
+ )
38
+ .with({ kind: 'retry' }, () =>
39
+ buildTextResult('boxxy could not read the pull request now and links it later'),
40
+ )
41
+ .with({ kind: 'refused', reason: 'linked-elsewhere' }, () =>
42
+ buildErrorResult('boxxy refused: this pull request belongs to another item', tellAgent),
43
+ )
44
+ .with({ kind: 'refused' }, (refused) => buildErrorResult(`boxxy refused: ${refused.reason}`))
45
+ .exhaustive();
46
+ }
@@ -0,0 +1,55 @@
1
+ import { match } from 'ts-pattern';
2
+ import { buildErrorResult, buildTextResult } from '../cli/build-answer-result.ts';
3
+ import { defaultSkillsURL, envNames } from '../constants.ts';
4
+ import type { CLIResult, CommandCall } from '../types.ts';
5
+ import { installSkills } from './install-skills.ts';
6
+ import { parseHookPayload, sessionStartPayloadSchema } from './parse-hook-payload.ts';
7
+ import { waitForAttempt } from './wait-for-attempt.ts';
8
+
9
+ // `boxxy hook session-start`. It asks which attempt the session carries, fetches the skills that
10
+ // the attempt's recipe lists at the rules' pin into the workspace, and binds the agent session to
11
+ // the attempt. A skill that is absent after the fetch goes to the server, which ends the attempt.
12
+ export async function runSessionStartHook(call: CommandCall): Promise<CLIResult> {
13
+ const text = await call.deps.readStdin();
14
+
15
+ const payload = parseHookPayload(sessionStartPayloadSchema, text);
16
+
17
+ const current = await waitForAttempt(call.client, call.deps);
18
+
19
+ if (current === null) {
20
+ return buildErrorResult('boxxy has no attempt for this session');
21
+ }
22
+
23
+ const missing = await installSkills({
24
+ pin: current.skills.pin,
25
+ names: current.skills.names,
26
+ workspace: payload.cwd,
27
+ baseURL: call.deps.env[envNames.skillsURL] ?? defaultSkillsURL,
28
+ runGit: call.deps.runGit,
29
+ });
30
+
31
+ const answer = await call.client.attempt.bindSession({
32
+ session: {
33
+ attempt: current.attempt.id,
34
+ agentSession: payload.session_id,
35
+ product: 'claude_code',
36
+ },
37
+ seenAt: new Date(call.deps.now()).toISOString(),
38
+ missingSkills: missing.length === 0 ? undefined : missing,
39
+ });
40
+
41
+ return match(answer)
42
+ .with({ kind: 'bound' }, { kind: 'held' }, () => buildTextResult(''))
43
+ .with({ kind: 'skill-missing' }, () =>
44
+ buildErrorResult(
45
+ `boxxy ends this attempt: the skills ${missing.join(', ')} are not in the pinned skills repository`,
46
+ ),
47
+ )
48
+ .with({ kind: 'no-attempt' }, { kind: 'not-current' }, (refused) =>
49
+ buildErrorResult(`boxxy did not bind this session: ${refused.kind}`),
50
+ )
51
+ .with({ kind: 'fault' }, (fault) =>
52
+ buildErrorResult(`this agent session is bound to attempt ${fault.holder}`),
53
+ )
54
+ .exhaustive();
55
+ }
@@ -0,0 +1,29 @@
1
+ import type { BoxxyClient } from '@boxxy/contract';
2
+ import { callerWait } from '../constants.ts';
3
+ import type { CLIDeps } from '../types.ts';
4
+
5
+ export type CurrentAttempt = Extract<
6
+ Awaited<ReturnType<BoxxyClient['attempt']['current']>>,
7
+ { kind: 'attempt' }
8
+ >;
9
+
10
+ // The attempt of this session, or null when the server still has none after the wait. The runner
11
+ // stores a session's caller after the worker answers the start, and a hook can run before that.
12
+ export async function waitForAttempt(
13
+ client: BoxxyClient,
14
+ deps: Pick<CLIDeps, 'sleep'>,
15
+ ): Promise<CurrentAttempt | null> {
16
+ for (let attempt = 1; attempt <= callerWait.tries; attempt += 1) {
17
+ const current = await client.attempt.current({});
18
+
19
+ if (current.kind === 'attempt') {
20
+ return current;
21
+ }
22
+
23
+ if (attempt < callerWait.tries) {
24
+ await deps.sleep(callerWait.delayMs);
25
+ }
26
+ }
27
+
28
+ return null;
29
+ }
package/src/index.ts CHANGED
@@ -1 +1,3 @@
1
- export { packageName } from './package-name.ts';
1
+ export { runCLI } from './cli/run-cli.ts';
2
+ export { runGit } from './run-git.ts';
3
+ export type { CLIDeps, CLIResult, GitResult } from './types.ts';
package/src/main.ts ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+ import { readFile } from 'node:fs/promises';
3
+ import { runCLI } from './cli/run-cli.ts';
4
+ import { runGit } from './run-git.ts';
5
+
6
+ // The entry of the `boxxy` command: the real environment, stdin, files and git, given to `runCLI`.
7
+ const result = await runCLI(
8
+ {
9
+ env: process.env,
10
+ readStdin: () => Bun.stdin.text(),
11
+ readFile: (path) => readFile(path, 'utf8'),
12
+ runGit,
13
+ sleep: (durationMs) => Bun.sleep(durationMs),
14
+ now: () => Date.now(),
15
+ },
16
+ process.argv.slice(2),
17
+ );
18
+
19
+ process.stdout.write(result.stdout);
20
+ process.stderr.write(result.stderr);
21
+ process.exit(result.exitCode);
package/src/run-git.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { GitResult } from './types.ts';
2
+
3
+ // Runs `git` and waits for it. Output and errors come back as one text.
4
+ export async function runGit(args: readonly string[], folder?: string): Promise<GitResult> {
5
+ const child = Bun.spawn(['git', ...args], {
6
+ ...(folder === undefined ? {} : { cwd: folder }),
7
+
8
+ // git never waits for a password: a hook has no one to answer
9
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
10
+ stdout: 'pipe',
11
+ stderr: 'pipe',
12
+ stdin: 'ignore',
13
+ });
14
+
15
+ const [output, errors, exitCode] = await Promise.all([
16
+ new Response(child.stdout).text(),
17
+ new Response(child.stderr).text(),
18
+ child.exited,
19
+ ]);
20
+
21
+ return { exitCode, output: `${output}${errors}` };
22
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { ParseArgsOptionsConfig } from 'node:util';
2
+ import type { BoxxyClient, ProcedureName } from '@boxxy/contract';
3
+
4
+ export interface GitResult {
5
+ readonly exitCode: number;
6
+ readonly output: string;
7
+ }
8
+
9
+ // Everything the CLI reads from or does to the machine. `main.ts` builds the real one.
10
+ export interface CLIDeps {
11
+ readonly env: Readonly<Record<string, string | undefined>>;
12
+
13
+ // sends a request to the server. Only a test passes it; the real CLI lets the client use the
14
+ // platform's fetch, so `HTTPS_PROXY` applies
15
+ readonly send?: (request: Request) => Promise<Response>;
16
+ readonly readStdin: () => Promise<string>;
17
+ readonly readFile: (path: string) => Promise<string>;
18
+
19
+ // runs `git` with the arguments, in the given folder when there is one
20
+ readonly runGit: (args: readonly string[], folder?: string) => Promise<GitResult>;
21
+ readonly sleep: (durationMs: number) => Promise<void>;
22
+ readonly now: () => number;
23
+ }
24
+
25
+ export interface CLIResult {
26
+ readonly exitCode: number;
27
+ readonly stdout: string;
28
+ readonly stderr: string;
29
+ }
30
+
31
+ // The flags and words of one command line, read through the command's option table.
32
+ export interface CommandArgs {
33
+ // the word at the position; throws a usage error naming `label` when it is missing
34
+ positional: (index: number, label: string) => string;
35
+
36
+ // every word from the position on, at least one
37
+ positionalsFrom: (index: number, label: string) => string[];
38
+ string: (name: string) => string | undefined;
39
+ requireString: (name: string) => string;
40
+ strings: (name: string) => string[];
41
+ integer: (name: string) => number | undefined;
42
+ oneOf: <Choice extends string>(name: string, choices: readonly Choice[]) => Choice | undefined;
43
+ requireOneOf: <Choice extends string>(name: string, choices: readonly Choice[]) => Choice;
44
+ }
45
+
46
+ export interface CommandCall {
47
+ readonly client: BoxxyClient;
48
+ readonly args: CommandArgs;
49
+ readonly deps: CLIDeps;
50
+ }
51
+
52
+ export interface Command {
53
+ // the words that name the command, such as `['item', 'file']`
54
+ readonly path: readonly string[];
55
+ readonly procedure: ProcedureName;
56
+ readonly summary: string;
57
+ readonly options: ParseArgsOptionsConfig;
58
+ readonly run: (call: CommandCall) => Promise<CLIResult>;
59
+ }
@@ -1 +0,0 @@
1
- export declare const packageName = "@boxxy/cli";
@@ -1 +0,0 @@
1
- export const packageName = '@boxxy/cli';