@boxxy/cli 0.0.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 +37 -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,132 @@
1
+ import type { BoxxyClient } from '@boxxy/contract';
2
+ import { match } from 'ts-pattern';
3
+ import {
4
+ buildAnswerResult,
5
+ buildErrorResult,
6
+ buildTextResult,
7
+ } from '../cli/build-answer-result.ts';
8
+ import { UsageError } from '../cli/usage-error.ts';
9
+ import { exitCodes } from '../constants.ts';
10
+ import type { CLIResult, Command } from '../types.ts';
11
+ import { describeCloseOutProblem } from './describe-close-out-problem.ts';
12
+
13
+ const evidenceKinds = ['test', 'ci-run', 'live-check'] as const;
14
+ const waitMessage = 'no attempt for this session yet; try again in 5 seconds';
15
+
16
+ // These commands run inside a session. None takes a session: the server finds the session from
17
+ // the proven caller, and the command asks `attempt.current` for the attempt to name.
18
+ export const attemptCommands: readonly Command[] = [
19
+ {
20
+ path: ['attempt', 'current'],
21
+ procedure: 'attempt.current',
22
+ summary: 'Shows the attempt of this session, its input and the schema of its close-out.',
23
+ options: {},
24
+ run: async (call) => {
25
+ const current = await call.client.attempt.current({});
26
+
27
+ return current.kind === 'attempt'
28
+ ? buildAnswerResult(current)
29
+ : buildErrorResult(waitMessage, exitCodes.retryLater);
30
+ },
31
+ },
32
+ {
33
+ path: ['attempt', 'evidence'],
34
+ procedure: 'attempt.evidence',
35
+ summary: 'Records one piece of evidence and prints its ID, to cite in the close-out.',
36
+ options: {
37
+ kind: { type: 'string' },
38
+ ref: { type: 'string' },
39
+ observed: { type: 'string' },
40
+ },
41
+ run: async (call) => {
42
+ const kind = call.args.requireOneOf('kind', evidenceKinds);
43
+ const ref = call.args.requireString('ref');
44
+
45
+ const attempt = await readCurrentAttempt(call.client);
46
+
47
+ if (attempt === null) {
48
+ return buildErrorResult(waitMessage, exitCodes.retryLater);
49
+ }
50
+
51
+ const answer = await call.client.attempt.evidence({
52
+ session: { attempt },
53
+ kind,
54
+ ref,
55
+ observed: call.args.string('observed'),
56
+ });
57
+
58
+ return describeEvidenceAnswer(answer);
59
+ },
60
+ },
61
+ {
62
+ path: ['attempt', 'close-out'],
63
+ procedure: 'attempt.closeOut',
64
+ summary: 'Sends the close-out, read as JSON from stdin. The first valid close-out is final.',
65
+ options: {},
66
+ run: async (call) => {
67
+ const text = await call.deps.readStdin();
68
+
69
+ const closeOut = parseJSON(text);
70
+
71
+ const attempt = await readCurrentAttempt(call.client);
72
+
73
+ if (attempt === null) {
74
+ return buildErrorResult(waitMessage, exitCodes.retryLater);
75
+ }
76
+
77
+ const answer = await call.client.attempt.closeOut({ session: { attempt }, closeOut });
78
+
79
+ return describeCloseOutAnswer(answer);
80
+ },
81
+ },
82
+ ];
83
+
84
+ async function readCurrentAttempt(client: BoxxyClient): Promise<string | null> {
85
+ const current = await client.attempt.current({});
86
+
87
+ return current.kind === 'attempt' ? current.attempt.id : null;
88
+ }
89
+
90
+ type EvidenceAnswer = Awaited<ReturnType<BoxxyClient['attempt']['evidence']>>;
91
+
92
+ function describeEvidenceAnswer(answer: EvidenceAnswer): CLIResult {
93
+ if (answer.kind === 'recorded') {
94
+ return buildTextResult(answer.factId);
95
+ }
96
+
97
+ const message = answer.reason === 'no-attempt' ? waitMessage : 'this attempt is over';
98
+
99
+ return buildErrorResult(message);
100
+ }
101
+
102
+ function parseJSON(text: string): unknown {
103
+ try {
104
+ const body: unknown = JSON.parse(text);
105
+
106
+ return body;
107
+ } catch {
108
+ throw new UsageError('stdin is not JSON');
109
+ }
110
+ }
111
+
112
+ type CloseOutAnswer = Awaited<ReturnType<BoxxyClient['attempt']['closeOut']>>;
113
+
114
+ function describeCloseOutAnswer(answer: CloseOutAnswer): CLIResult {
115
+ return match(answer)
116
+ .with({ kind: 'recorded' }, (recorded) => buildTextResult(recorded.factId))
117
+ .with({ kind: 'rejected' }, (rejected) =>
118
+ buildErrorResult(
119
+ rejected.problems.map((problem) => describeCloseOutProblem(problem)).join('\n'),
120
+ ),
121
+ )
122
+ .with({ kind: 'refused', reason: 'no-attempt' }, () => buildErrorResult(waitMessage))
123
+ .with({ kind: 'refused', reason: 'already-closed-out' }, () =>
124
+ buildErrorResult(
125
+ 'this attempt already has a close-out, and the first valid close-out is final',
126
+ ),
127
+ )
128
+ .with({ kind: 'refused', reason: 'not-current' }, () =>
129
+ buildErrorResult('this attempt is over'),
130
+ )
131
+ .exhaustive();
132
+ }
@@ -0,0 +1,33 @@
1
+ import type { CloseOutProblem } from '@boxxy/model';
2
+ import { match } from 'ts-pattern';
3
+
4
+ // One line for the agent to act on. The server lists every problem at once.
5
+ export function describeCloseOutProblem(problem: CloseOutProblem): string {
6
+ return match(problem)
7
+ .with(
8
+ { kind: 'schema' },
9
+ (found) => `${found.path === '' ? '(body)' : found.path}: ${found.message}`,
10
+ )
11
+ .with({ kind: 'unknown-criterion' }, (found) => `unknown criterion ${found.criterion}`)
12
+ .with(
13
+ { kind: 'foreign-evidence' },
14
+ (found) => `evidence ${found.evidence} belongs to another attempt`,
15
+ )
16
+ .with(
17
+ { kind: 'built-without-evidence' },
18
+ (found) => `built criteria with no evidence: ${found.criteria.join(', ')}`,
19
+ )
20
+ .with({ kind: 'no-acceptance' }, () => 'the close-out lists no acceptance criterion')
21
+ .with({ kind: 'unknown-item' }, (found) => `unknown item ${found.item}`)
22
+ .with({ kind: 'unknown-kind' }, (found) => `unknown item kind ${found.itemKind}`)
23
+ .with(
24
+ { kind: 'refused-relation' },
25
+ (found) => `relation ${found.type} to ${found.target} refused: ${found.reason}`,
26
+ )
27
+ .with({ kind: 'phase-rule' }, (found) => found.message)
28
+ .with(
29
+ { kind: 'too-large' },
30
+ (found) => `the close-out is ${found.bytes} bytes as stored; the limit is ${found.max}`,
31
+ )
32
+ .exhaustive();
33
+ }
@@ -0,0 +1,21 @@
1
+ import { runPostToolUseHook } from '../hooks/run-post-tool-use-hook.ts';
2
+ import { runSessionStartHook } from '../hooks/run-session-start-hook.ts';
3
+ import type { Command } from '../types.ts';
4
+
5
+ // The hook plug-in in the agent image runs these. Each reads Claude Code's hook payload on stdin.
6
+ export const hookCommands: readonly Command[] = [
7
+ {
8
+ path: ['hook', 'session-start'],
9
+ procedure: 'attempt.bindSession',
10
+ summary: 'Fetches the pinned skills, then binds the agent session to its attempt.',
11
+ options: {},
12
+ run: runSessionStartHook,
13
+ },
14
+ {
15
+ path: ['hook', 'post-tool-use'],
16
+ procedure: 'attempt.recordPullRequest',
17
+ summary: 'Links the pull request that `gh pr create` printed to the attempt.',
18
+ options: {},
19
+ run: runPostToolUseHook,
20
+ },
21
+ ];
@@ -0,0 +1,140 @@
1
+ import { makeAnswerRun } from '../cli/make-answer-run.ts';
2
+ import type { Command } from '../types.ts';
3
+ import { parseCriterionFlag } from './parse-criterion-flag.ts';
4
+
5
+ const designModes = ['agent', 'attended'] as const;
6
+ const itemStates = ['idea', 'ready', 'blocked', 'running', 'waiting', 'done', 'archived'] as const;
7
+
8
+ export const itemCommands: readonly Command[] = [
9
+ {
10
+ path: ['item', 'file'],
11
+ procedure: 'item.file',
12
+ summary: 'Files a new item of work.',
13
+ options: {
14
+ repo: { type: 'string' },
15
+ title: { type: 'string' },
16
+ outcome: { type: 'string' },
17
+ acceptance: { type: 'string', multiple: true },
18
+ kind: { type: 'string' },
19
+ 'design-mode': { type: 'string' },
20
+ 'blocked-by': { type: 'string', multiple: true },
21
+ label: { type: 'string', multiple: true },
22
+ },
23
+ run: makeAnswerRun((call) =>
24
+ call.client.item.file({
25
+ repo: call.args.requireString('repo'),
26
+ title: call.args.requireString('title'),
27
+ outcome: call.args.requireString('outcome'),
28
+ acceptance: call.args.strings('acceptance'),
29
+ blockedBy: call.args.strings('blocked-by'),
30
+ labels: call.args.strings('label'),
31
+ kind: call.args.string('kind'),
32
+ designMode: call.args.oneOf('design-mode', designModes),
33
+ }),
34
+ ),
35
+ },
36
+ {
37
+ path: ['item', 'edit'],
38
+ procedure: 'item.edit',
39
+ summary: 'Replaces the given fields of an item. A flag that is left out changes nothing.',
40
+ options: {
41
+ title: { type: 'string' },
42
+ outcome: { type: 'string' },
43
+ acceptance: { type: 'string', multiple: true },
44
+ 'design-mode': { type: 'string' },
45
+ label: { type: 'string', multiple: true },
46
+ },
47
+ run: makeAnswerRun((call) => {
48
+ const acceptance = call.args.strings('acceptance');
49
+ const labels = call.args.strings('label');
50
+
51
+ return call.client.item.edit({
52
+ item: call.args.positional(0, 'item'),
53
+ title: call.args.string('title'),
54
+ outcome: call.args.string('outcome'),
55
+ designMode: call.args.oneOf('design-mode', designModes),
56
+ acceptance:
57
+ acceptance.length === 0
58
+ ? undefined
59
+ : acceptance.map((entry) => parseCriterionFlag(entry)),
60
+ labels: labels.length === 0 ? undefined : labels,
61
+ });
62
+ }),
63
+ },
64
+ {
65
+ path: ['item', 'close'],
66
+ procedure: 'item.close',
67
+ summary: 'Closes an item and ends its live attempt. With --of, closes it as a duplicate.',
68
+ options: { reason: { type: 'string' }, of: { type: 'string' } },
69
+ run: makeAnswerRun((call) =>
70
+ call.client.item.close({
71
+ item: call.args.positional(0, 'item'),
72
+ reason: call.args.requireString('reason'),
73
+ of: call.args.string('of'),
74
+ }),
75
+ ),
76
+ },
77
+ {
78
+ path: ['item', 'retry'],
79
+ procedure: 'item.retry',
80
+ summary: 'Lifts a hold and resets the attempt counts. With --phase, sets the position.',
81
+ options: { phase: { type: 'string' } },
82
+ run: makeAnswerRun((call) =>
83
+ call.client.item.retry({
84
+ item: call.args.positional(0, 'item'),
85
+ phase: call.args.string('phase'),
86
+ }),
87
+ ),
88
+ },
89
+ {
90
+ path: ['item', 'wake'],
91
+ procedure: 'item.wake',
92
+ summary: 'Asks boxxy to wake the sleeping session of an item.',
93
+ options: {},
94
+ run: makeAnswerRun((call) => call.client.item.wake({ item: call.args.positional(0, 'item') })),
95
+ },
96
+ {
97
+ path: ['item', 'note'],
98
+ procedure: 'item.note',
99
+ summary: 'Appends a note to an item. The next attempt reads it.',
100
+ options: { text: { type: 'string' } },
101
+ run: makeAnswerRun((call) =>
102
+ call.client.item.note({
103
+ item: call.args.positional(0, 'item'),
104
+ text: call.args.requireString('text'),
105
+ }),
106
+ ),
107
+ },
108
+ {
109
+ path: ['item', 'show'],
110
+ procedure: 'item.get',
111
+ summary: 'Shows one item with its position, notes, attempts, relations and pull requests.',
112
+ options: {},
113
+ run: makeAnswerRun((call) => call.client.item.get({ item: call.args.positional(0, 'item') })),
114
+ },
115
+ {
116
+ path: ['item', 'list'],
117
+ procedure: 'item.list',
118
+ summary: 'Lists items, newest first, with filters.',
119
+ options: {
120
+ state: { type: 'string' },
121
+ repo: { type: 'string' },
122
+ phase: { type: 'string' },
123
+ text: { type: 'string' },
124
+ label: { type: 'string' },
125
+ limit: { type: 'string' },
126
+ after: { type: 'string' },
127
+ },
128
+ run: makeAnswerRun((call) =>
129
+ call.client.item.list({
130
+ state: call.args.oneOf('state', itemStates),
131
+ repo: call.args.string('repo'),
132
+ phase: call.args.string('phase'),
133
+ text: call.args.string('text'),
134
+ label: call.args.string('label'),
135
+ limit: call.args.integer('limit'),
136
+ after: call.args.string('after'),
137
+ }),
138
+ ),
139
+ },
140
+ ];
@@ -0,0 +1,13 @@
1
+ const keptPattern = /^\[(?<id>[^\]\s]+)\]\s+(?<text>\S.*)$/u;
2
+
3
+ // One `--acceptance` value of `item edit`. `[c-3] text` keeps the criterion c-3, with its
4
+ // evidence, and gives it the text. Any other value is a new criterion.
5
+ export function parseCriterionFlag(entry: string): { id?: string; text: string } {
6
+ const groups = keptPattern.exec(entry)?.groups;
7
+
8
+ if (groups?.['id'] === undefined || groups['text'] === undefined) {
9
+ return { text: entry };
10
+ }
11
+
12
+ return { id: groups['id'], text: groups['text'] };
13
+ }
@@ -0,0 +1,49 @@
1
+ import { makeAnswerRun } from '../cli/make-answer-run.ts';
2
+ import type { Command } from '../types.ts';
3
+
4
+ export const planCommands: readonly Command[] = [
5
+ {
6
+ path: ['defer'],
7
+ procedure: 'plan.defer',
8
+ summary: 'Holds an item back from dispatch until a time, or until undefer.',
9
+ options: { until: { type: 'string' }, reason: { type: 'string' } },
10
+ run: makeAnswerRun((call) =>
11
+ call.client.plan.defer({
12
+ item: call.args.positional(0, 'item'),
13
+ reason: call.args.requireString('reason'),
14
+ until: call.args.string('until'),
15
+ }),
16
+ ),
17
+ },
18
+ {
19
+ path: ['undefer'],
20
+ procedure: 'plan.undefer',
21
+ summary: 'Ends the deferral of an item.',
22
+ options: {},
23
+ run: makeAnswerRun((call) =>
24
+ call.client.plan.undefer({ item: call.args.positional(0, 'item') }),
25
+ ),
26
+ },
27
+ {
28
+ path: ['expedite'],
29
+ procedure: 'plan.expedite',
30
+ summary: 'Puts items at the front of their pools, in the order given.',
31
+ options: { reason: { type: 'string' }, expires: { type: 'string' } },
32
+ run: makeAnswerRun((call) =>
33
+ call.client.plan.expedite({
34
+ items: call.args.positionalsFrom(0, 'item'),
35
+ reason: call.args.requireString('reason'),
36
+ expires: call.args.string('expires'),
37
+ }),
38
+ ),
39
+ },
40
+ {
41
+ path: ['unexpedite'],
42
+ procedure: 'plan.unexpedite',
43
+ summary: 'Ends the expedite of an item.',
44
+ options: {},
45
+ run: makeAnswerRun((call) =>
46
+ call.client.plan.unexpedite({ item: call.args.positional(0, 'item') }),
47
+ ),
48
+ },
49
+ ];
@@ -0,0 +1,31 @@
1
+ import { makeAnswerRun } from '../cli/make-answer-run.ts';
2
+ import type { Command } from '../types.ts';
3
+
4
+ export const relationCommands: readonly Command[] = [
5
+ {
6
+ path: ['relation', 'add'],
7
+ procedure: 'relation.add',
8
+ summary: 'Adds a relation from one item to another, such as "blocks".',
9
+ options: {},
10
+ run: makeAnswerRun((call) =>
11
+ call.client.relation.add({
12
+ source: call.args.positional(0, 'source'),
13
+ type: call.args.positional(1, 'type'),
14
+ target: call.args.positional(2, 'target'),
15
+ }),
16
+ ),
17
+ },
18
+ {
19
+ path: ['relation', 'remove'],
20
+ procedure: 'relation.remove',
21
+ summary: 'Removes a relation between two items.',
22
+ options: {},
23
+ run: makeAnswerRun((call) =>
24
+ call.client.relation.remove({
25
+ source: call.args.positional(0, 'source'),
26
+ type: call.args.positional(1, 'type'),
27
+ target: call.args.positional(2, 'target'),
28
+ }),
29
+ ),
30
+ },
31
+ ];
@@ -0,0 +1,23 @@
1
+ import { makeAnswerRun } from '../cli/make-answer-run.ts';
2
+ import type { Command } from '../types.ts';
3
+
4
+ export const reportCommands: readonly Command[] = [
5
+ {
6
+ path: ['cost'],
7
+ procedure: 'report.cost',
8
+ summary: 'Shows what one item cost, from pepys.',
9
+ options: {},
10
+ run: makeAnswerRun((call) =>
11
+ call.client.report.cost({ item: call.args.positional(0, 'item') }),
12
+ ),
13
+ },
14
+ {
15
+ path: ['times'],
16
+ procedure: 'report.stageTimes',
17
+ summary: 'Shows how long one item took in each step, from pepys.',
18
+ options: {},
19
+ run: makeAnswerRun((call) =>
20
+ call.client.report.stageTimes({ item: call.args.positional(0, 'item') }),
21
+ ),
22
+ },
23
+ ];
@@ -0,0 +1,45 @@
1
+ import { makeAnswerRun } from '../cli/make-answer-run.ts';
2
+ import type { Command } from '../types.ts';
3
+
4
+ // The commands that act on the server as a whole.
5
+ export const runnerCommands: readonly Command[] = [
6
+ {
7
+ path: ['status'],
8
+ procedure: 'runner.status',
9
+ summary: 'Shows the limits, the live attempts, the strays, the unsent notices and the outbox.',
10
+ options: {},
11
+ run: makeAnswerRun((call) => call.client.runner.status({})),
12
+ },
13
+ {
14
+ path: ['runner', 'pause'],
15
+ procedure: 'runner.pause',
16
+ summary: 'Pauses the runner. It starts no new attempt until resume.',
17
+ options: {},
18
+ run: makeAnswerRun((call) => call.client.runner.pause({})),
19
+ },
20
+ {
21
+ path: ['runner', 'resume'],
22
+ procedure: 'runner.resume',
23
+ summary: 'Resumes the runner.',
24
+ options: {},
25
+ run: makeAnswerRun((call) => call.client.runner.resume({})),
26
+ },
27
+ {
28
+ path: ['outbox', 'retry'],
29
+ procedure: 'outbox.retry',
30
+ summary: 'Clears the park mark of every parked outbox row.',
31
+ options: {},
32
+ run: makeAnswerRun((call) => call.client.outbox.retry({})),
33
+ },
34
+ {
35
+ path: ['rules', 'dry-run'],
36
+ procedure: 'rules.dryRun',
37
+ summary: 'Shows where each open item would stand under a rules file. It writes nothing.',
38
+ options: {},
39
+ run: makeAnswerRun(async (call) => {
40
+ const rules = await call.deps.readFile(call.args.positional(0, 'file'));
41
+
42
+ return call.client.rules.dryRun({ rules });
43
+ }),
44
+ },
45
+ ];
@@ -0,0 +1,19 @@
1
+ // 2 means the command line is wrong, or the server has no attempt for this session yet
2
+ export const exitCodes = { ok: 0, failed: 1, usage: 2, retryLater: 2 } as const;
3
+
4
+ // The session-start hook can run before the server knows the session's caller, because the runner
5
+ // stores the caller after the worker answers the start. The hook asks again for this long.
6
+ export const callerWait = { tries: 6, delayMs: 5000 } as const;
7
+
8
+ // where the server's address is read from, and where a deployment points the skills fetch
9
+ export const envNames = {
10
+ url: 'BOXXY_URL',
11
+ skillsURL: 'BOXXY_SKILLS_URL',
12
+ } as const;
13
+
14
+ export const defaultSkillsURL = 'https://github.com';
15
+
16
+ // the folder of the skills repository that holds one folder per skill, and the folder of the
17
+ // workspace that the agent reads skills from
18
+ export const skillsRepoFolder = 'skills';
19
+ export const workspaceSkillsFolder = '.claude/skills';
@@ -0,0 +1,50 @@
1
+ import type { BoxxyClient } from '@boxxy/contract';
2
+ import { createORPCClient } from '@orpc/client';
3
+ import { RPCLink } from '@orpc/client/fetch';
4
+ import { UsageError } from './cli/usage-error.ts';
5
+ import { envNames } from './constants.ts';
6
+ import type { CLIDeps } from './types.ts';
7
+
8
+ // The typed client of the server's RPC route. It sends no token and no identity header: on the
9
+ // owner's path the listener needs none, and in a session the broker adds both on the way out. The
10
+ // client uses the platform's fetch unless a test passes `send`.
11
+ export function createClient(deps: CLIDeps): BoxxyClient {
12
+ const address = deps.env[envNames.url];
13
+
14
+ if (address === undefined || address === '') {
15
+ throw new UsageError(`${envNames.url} is not set`);
16
+ }
17
+
18
+ const target = parseServerAddress(address);
19
+
20
+ const link = new RPCLink({
21
+ origin: target.origin,
22
+ url: target.url,
23
+ ...makeLinkOptions(deps.send),
24
+ });
25
+
26
+ return createORPCClient<BoxxyClient>(link);
27
+ }
28
+
29
+ function parseServerAddress(address: string): { origin: string; url: `/${string}` } {
30
+ const parsed = URL.parse(address);
31
+
32
+ if (parsed === null || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
33
+ throw new UsageError(`${envNames.url} is not an http or https address`);
34
+ }
35
+
36
+ const route = `${parsed.pathname.replace(/\/+$/u, '')}/rpc`.replace(/^\/+/u, '');
37
+
38
+ return { origin: parsed.origin, url: `/${route}` };
39
+ }
40
+
41
+ // the link sends through the platform's fetch unless it is given another way to send
42
+ function makeLinkOptions(send: CLIDeps['send']) {
43
+ if (send === undefined) {
44
+ return {};
45
+ }
46
+
47
+ const sendRequest = (url: string, init: RequestInit) => send(new Request(url, init));
48
+
49
+ return { fetch: sendRequest };
50
+ }
@@ -0,0 +1,116 @@
1
+ import { cp, mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { skillsRepoFolder, workspaceSkillsFolder } from '../constants.ts';
5
+ import type { CLIDeps } from '../types.ts';
6
+
7
+ interface SkillsPin {
8
+ // the repository, as owner/name
9
+ readonly source: string;
10
+
11
+ // one full commit
12
+ readonly ref: string;
13
+ }
14
+
15
+ export interface InstallSkillsInput {
16
+ // the rules' pin; null when the rules have none
17
+ readonly pin: SkillsPin | null;
18
+ readonly names: readonly string[];
19
+
20
+ // the folder the agent works in; the skills land in its `.claude/skills`
21
+ readonly workspace: string;
22
+
23
+ // where `<owner>/<name>.git` is fetched from
24
+ readonly baseURL: string;
25
+ readonly runGit: CLIDeps['runGit'];
26
+ }
27
+
28
+ const sourcePattern = /^[\w.-]+\/[\w.-]+$/u;
29
+ const refPattern = /^[0-9a-f]{40,64}$/u;
30
+
31
+ // Copies each named skill at the pin into the workspace and returns the names that are not there
32
+ // afterwards. A skill is there when its folder holds a `SKILL.md`. A fetch that fails leaves every
33
+ // name missing, because the attempt cannot tell a skill it lacks from one it could not read.
34
+ export async function installSkills(input: InstallSkillsInput): Promise<string[]> {
35
+ if (input.names.length === 0) {
36
+ return [];
37
+ }
38
+
39
+ const pin = input.pin;
40
+
41
+ if (pin === null || !sourcePattern.test(pin.source) || !refPattern.test(pin.ref)) {
42
+ return [...input.names];
43
+ }
44
+
45
+ const scratch = await mkdtemp(join(tmpdir(), 'boxxy-skills-'));
46
+
47
+ try {
48
+ const fetched = await runFetch(input, scratch, pin);
49
+
50
+ if (!fetched) {
51
+ return [...input.names];
52
+ }
53
+
54
+ const missing: string[] = [];
55
+
56
+ for (const name of input.names) {
57
+ if (!(await installSkill(input, scratch, name))) {
58
+ missing.push(name);
59
+ }
60
+ }
61
+
62
+ return missing;
63
+ } finally {
64
+ await rm(scratch, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ async function runFetch(
69
+ input: InstallSkillsInput,
70
+ scratch: string,
71
+ pin: SkillsPin,
72
+ ): Promise<boolean> {
73
+ const init = await input.runGit(['init', '--quiet', scratch]);
74
+
75
+ if (init.exitCode !== 0) {
76
+ return false;
77
+ }
78
+
79
+ const fetched = await input.runGit(
80
+ ['fetch', '--quiet', '--depth', '1', `${input.baseURL}/${pin.source}.git`, pin.ref],
81
+ scratch,
82
+ );
83
+
84
+ return fetched.exitCode === 0;
85
+ }
86
+
87
+ const namePattern = /^[a-z0-9][a-z0-9._-]*$/u;
88
+
89
+ async function installSkill(
90
+ input: InstallSkillsInput,
91
+ scratch: string,
92
+ name: string,
93
+ ): Promise<boolean> {
94
+ if (!namePattern.test(name)) {
95
+ return false;
96
+ }
97
+
98
+ const checkout = await input.runGit(
99
+ ['checkout', '--quiet', 'FETCH_HEAD', '--', `${skillsRepoFolder}/${name}`],
100
+ scratch,
101
+ );
102
+
103
+ if (checkout.exitCode !== 0) {
104
+ return false;
105
+ }
106
+
107
+ const target = join(input.workspace, workspaceSkillsFolder, name);
108
+
109
+ await rm(target, { recursive: true, force: true });
110
+ await mkdir(dirname(target), { recursive: true });
111
+ await cp(join(scratch, skillsRepoFolder, name), target, { recursive: true });
112
+
113
+ const skillFile = await stat(join(target, 'SKILL.md')).catch(() => null);
114
+
115
+ return skillFile?.isFile() === true;
116
+ }