@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.
- package/README.md +37 -4
- package/dist/cli/build-answer-result.d.ts +4 -0
- package/dist/cli/format-help.d.ts +2 -0
- package/dist/cli/make-answer-run.d.ts +2 -0
- package/dist/cli/parse-command-args.d.ts +2 -0
- package/dist/cli/pick-command.d.ts +13 -0
- package/dist/cli/run-cli.d.ts +2 -0
- package/dist/cli/usage-error.d.ts +3 -0
- package/dist/commands/all-commands.d.ts +2 -0
- package/dist/commands/attempt-commands.d.ts +2 -0
- package/dist/commands/describe-close-out-problem.d.ts +2 -0
- package/dist/commands/hook-commands.d.ts +2 -0
- package/dist/commands/item-commands.d.ts +2 -0
- package/dist/commands/parse-criterion-flag.d.ts +4 -0
- package/dist/commands/plan-commands.d.ts +2 -0
- package/dist/commands/relation-commands.d.ts +2 -0
- package/dist/commands/report-commands.d.ts +2 -0
- package/dist/commands/runner-commands.d.ts +2 -0
- package/dist/constants.d.ts +17 -0
- package/dist/create-client.d.ts +3 -0
- package/dist/hooks/install-skills.d.ts +14 -0
- package/dist/hooks/parse-hook-payload.d.ts +16 -0
- package/dist/hooks/pick-pull-request-url.d.ts +2 -0
- package/dist/hooks/run-post-tool-use-hook.d.ts +2 -0
- package/dist/hooks/run-session-start-hook.d.ts +2 -0
- package/dist/hooks/wait-for-attempt.d.ts +6 -0
- package/dist/index.d.ts +3 -1
- package/dist/main.d.ts +2 -0
- package/dist/run-git.d.ts +2 -0
- package/dist/test-utils/build-mock-cli-deps.d.ts +2 -0
- package/dist/test-utils/create-skills-repo.d.ts +10 -0
- package/dist/types.d.ts +42 -0
- package/hook-plugin/.claude-plugin/plugin.json +4 -0
- package/hook-plugin/hooks/hooks.json +16 -0
- package/package.json +15 -2
- package/src/cli/build-answer-result.ts +24 -0
- package/src/cli/format-help.ts +19 -0
- package/src/cli/make-answer-run.ts +11 -0
- package/src/cli/parse-command-args.ts +91 -0
- package/src/cli/pick-command.ts +44 -0
- package/src/cli/run-cli.ts +46 -0
- package/src/cli/usage-error.ts +4 -0
- package/src/commands/all-commands.ts +19 -0
- package/src/commands/attempt-commands.ts +132 -0
- package/src/commands/describe-close-out-problem.ts +33 -0
- package/src/commands/hook-commands.ts +21 -0
- package/src/commands/item-commands.ts +140 -0
- package/src/commands/parse-criterion-flag.ts +13 -0
- package/src/commands/plan-commands.ts +49 -0
- package/src/commands/relation-commands.ts +31 -0
- package/src/commands/report-commands.ts +23 -0
- package/src/commands/runner-commands.ts +45 -0
- package/src/constants.ts +19 -0
- package/src/create-client.ts +50 -0
- package/src/hooks/install-skills.ts +116 -0
- package/src/hooks/parse-hook-payload.ts +48 -0
- package/src/hooks/pick-pull-request-url.ts +21 -0
- package/src/hooks/run-post-tool-use-hook.ts +46 -0
- package/src/hooks/run-session-start-hook.ts +55 -0
- package/src/hooks/wait-for-attempt.ts +29 -0
- package/src/index.ts +3 -1
- package/src/main.ts +21 -0
- package/src/run-git.ts +22 -0
- package/src/types.ts +59 -0
- package/dist/package-name.d.ts +0 -1
- package/src/package-name.ts +0 -1
package/README.md
CHANGED
|
@@ -5,17 +5,50 @@
|
|
|
5
5
|
<a href="https://www.npmjs.com/package/@boxxy/cli"><img src="https://img.shields.io/npm/v/@boxxy/cli" alt="npm version"></a>
|
|
6
6
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
7
|
</p>
|
|
8
|
-
</div>
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/design/boxxy/README.md">Documentation</a>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
The `boxxy` command: one command for each procedure of the boxxy API, the close-out command that an
|
|
15
|
+
agent runs inside a session, and the two hooks that bind a session and link its pull request.
|
|
14
16
|
|
|
15
17
|
## Install
|
|
16
18
|
|
|
19
|
+
Install the package from [npm](https://www.npmjs.com/package/@boxxy/cli) with Bun:
|
|
20
|
+
|
|
17
21
|
```bash
|
|
18
22
|
bun add @boxxy/cli
|
|
19
23
|
```
|
|
20
24
|
|
|
21
25
|
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Set the address of the server, then run a command:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
BOXXY_URL=<server_url> boxxy item list --state ready
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `<server_url>` is the origin of the server's owner listener. In a worker session an atc auth
|
|
36
|
+
profile sets it to the broker's address.
|
|
37
|
+
|
|
38
|
+
`boxxy --help` prints the usage line of every command. A command prints the server's answer as JSON
|
|
39
|
+
and exits 0, exits 1 on a refusal or an error, and exits 2 on a wrong command line.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- `boxxy item`, `boxxy relation`, `boxxy defer`, `boxxy expedite`, `boxxy status` and the other
|
|
44
|
+
owner commands.
|
|
45
|
+
- `boxxy attempt current`, `boxxy attempt evidence` and `boxxy attempt close-out`, for an agent in a
|
|
46
|
+
session.
|
|
47
|
+
- `boxxy hook session-start` and `boxxy hook post-tool-use`, which the plug-in in `hook-plugin/`
|
|
48
|
+
runs.
|
|
49
|
+
- `runCLI`, which runs one command line against dependencies the caller passes in.
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
[The CLI and the hooks](../../docs/design/boxxy/v1-cli.md) lists the environment, the exit codes and
|
|
54
|
+
what each hook does.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CLIResult } from '../types.ts';
|
|
2
|
+
export declare function buildAnswerResult(answer: unknown): CLIResult;
|
|
3
|
+
export declare function buildTextResult(text: string, exitCode?: number): CLIResult;
|
|
4
|
+
export declare function buildErrorResult(message: string, exitCode?: number): CLIResult;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Command } from '../types.ts';
|
|
2
|
+
export type PickedCommand = {
|
|
3
|
+
readonly kind: 'command';
|
|
4
|
+
readonly command: Command;
|
|
5
|
+
readonly words: readonly string[];
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'help';
|
|
8
|
+
readonly prefix: readonly string[];
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: 'unknown';
|
|
11
|
+
readonly word: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function pickCommand(commands: readonly Command[], argv: readonly string[]): PickedCommand;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const exitCodes: {
|
|
2
|
+
readonly ok: 0;
|
|
3
|
+
readonly failed: 1;
|
|
4
|
+
readonly usage: 2;
|
|
5
|
+
readonly retryLater: 2;
|
|
6
|
+
};
|
|
7
|
+
export declare const callerWait: {
|
|
8
|
+
readonly tries: 6;
|
|
9
|
+
readonly delayMs: 5000;
|
|
10
|
+
};
|
|
11
|
+
export declare const envNames: {
|
|
12
|
+
readonly url: 'BOXXY_URL';
|
|
13
|
+
readonly skillsURL: 'BOXXY_SKILLS_URL';
|
|
14
|
+
};
|
|
15
|
+
export declare const defaultSkillsURL = "https://github.com";
|
|
16
|
+
export declare const skillsRepoFolder = "skills";
|
|
17
|
+
export declare const workspaceSkillsFolder = ".claude/skills";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CLIDeps } from '../types.ts';
|
|
2
|
+
interface SkillsPin {
|
|
3
|
+
readonly source: string;
|
|
4
|
+
readonly ref: string;
|
|
5
|
+
}
|
|
6
|
+
export interface InstallSkillsInput {
|
|
7
|
+
readonly pin: SkillsPin | null;
|
|
8
|
+
readonly names: readonly string[];
|
|
9
|
+
readonly workspace: string;
|
|
10
|
+
readonly baseURL: string;
|
|
11
|
+
readonly runGit: CLIDeps['runGit'];
|
|
12
|
+
}
|
|
13
|
+
export declare function installSkills(input: InstallSkillsInput): Promise<string[]>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const sessionStartPayloadSchema: z.ZodObject<{
|
|
3
|
+
session_id: z.ZodString;
|
|
4
|
+
cwd: z.ZodString;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export declare const postToolUsePayloadSchema: z.ZodObject<{
|
|
7
|
+
tool_name: z.ZodString;
|
|
8
|
+
tool_input: z.ZodOptional<z.ZodObject<{
|
|
9
|
+
command: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strip>>;
|
|
11
|
+
tool_response: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
12
|
+
stdout: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, z.core.$strip>]>>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
export type PostToolUsePayload = z.infer<typeof postToolUsePayloadSchema>;
|
|
16
|
+
export declare function parseHookPayload<Schema extends z.ZodType>(schema: Schema, text: string): z.output<Schema>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BoxxyClient } from '@boxxy/contract';
|
|
2
|
+
import type { CLIDeps } from '../types.ts';
|
|
3
|
+
export type CurrentAttempt = Extract<Awaited<ReturnType<BoxxyClient['attempt']['current']>>, {
|
|
4
|
+
kind: 'attempt';
|
|
5
|
+
}>;
|
|
6
|
+
export declare function waitForAttempt(client: BoxxyClient, deps: Pick<CLIDeps, 'sleep'>): Promise<CurrentAttempt | null>;
|
package/dist/index.d.ts
CHANGED
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SkillsRepoInput {
|
|
2
|
+
readonly folder: string;
|
|
3
|
+
readonly skills: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
4
|
+
}
|
|
5
|
+
export interface SkillsRepo {
|
|
6
|
+
readonly baseURL: string;
|
|
7
|
+
readonly source: string;
|
|
8
|
+
readonly ref: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function createSkillsRepo(input: SkillsRepoInput): Promise<SkillsRepo>;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ParseArgsOptionsConfig } from 'node:util';
|
|
2
|
+
import type { BoxxyClient, ProcedureName } from '@boxxy/contract';
|
|
3
|
+
export interface GitResult {
|
|
4
|
+
readonly exitCode: number;
|
|
5
|
+
readonly output: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CLIDeps {
|
|
8
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
readonly send?: (request: Request) => Promise<Response>;
|
|
10
|
+
readonly readStdin: () => Promise<string>;
|
|
11
|
+
readonly readFile: (path: string) => Promise<string>;
|
|
12
|
+
readonly runGit: (args: readonly string[], folder?: string) => Promise<GitResult>;
|
|
13
|
+
readonly sleep: (durationMs: number) => Promise<void>;
|
|
14
|
+
readonly now: () => number;
|
|
15
|
+
}
|
|
16
|
+
export interface CLIResult {
|
|
17
|
+
readonly exitCode: number;
|
|
18
|
+
readonly stdout: string;
|
|
19
|
+
readonly stderr: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CommandArgs {
|
|
22
|
+
positional: (index: number, label: string) => string;
|
|
23
|
+
positionalsFrom: (index: number, label: string) => string[];
|
|
24
|
+
string: (name: string) => string | undefined;
|
|
25
|
+
requireString: (name: string) => string;
|
|
26
|
+
strings: (name: string) => string[];
|
|
27
|
+
integer: (name: string) => number | undefined;
|
|
28
|
+
oneOf: <Choice extends string>(name: string, choices: readonly Choice[]) => Choice | undefined;
|
|
29
|
+
requireOneOf: <Choice extends string>(name: string, choices: readonly Choice[]) => Choice;
|
|
30
|
+
}
|
|
31
|
+
export interface CommandCall {
|
|
32
|
+
readonly client: BoxxyClient;
|
|
33
|
+
readonly args: CommandArgs;
|
|
34
|
+
readonly deps: CLIDeps;
|
|
35
|
+
}
|
|
36
|
+
export interface Command {
|
|
37
|
+
readonly path: readonly string[];
|
|
38
|
+
readonly procedure: ProcedureName;
|
|
39
|
+
readonly summary: string;
|
|
40
|
+
readonly options: ParseArgsOptionsConfig;
|
|
41
|
+
readonly run: (call: CommandCall) => Promise<CLIResult>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "startup|resume",
|
|
6
|
+
"hooks": [{ "type": "command", "command": "boxxy hook session-start" }]
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"PostToolUse": [
|
|
10
|
+
{
|
|
11
|
+
"matcher": "Bash",
|
|
12
|
+
"hooks": [{ "type": "command", "command": "boxxy hook post-tool-use" }]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The boxxy command line",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/apps/cli#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
"url": "git+https://github.com/zgeoff/boxxy.git",
|
|
11
11
|
"directory": "apps/cli"
|
|
12
12
|
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"boxxy": "./src/main.ts"
|
|
15
|
+
},
|
|
13
16
|
"files": [
|
|
14
17
|
"src",
|
|
15
18
|
"!src/**/*.test.ts",
|
|
19
|
+
"!src/test-utils",
|
|
20
|
+
"hook-plugin",
|
|
16
21
|
"dist"
|
|
17
22
|
],
|
|
18
23
|
"type": "module",
|
|
@@ -20,7 +25,8 @@
|
|
|
20
25
|
".": {
|
|
21
26
|
"types": "./dist/index.d.ts",
|
|
22
27
|
"default": "./src/index.ts"
|
|
23
|
-
}
|
|
28
|
+
},
|
|
29
|
+
"./test-utils/*": "./src/test-utils/*.ts"
|
|
24
30
|
},
|
|
25
31
|
"publishConfig": {
|
|
26
32
|
"access": "public"
|
|
@@ -29,6 +35,13 @@
|
|
|
29
35
|
"build": "rm -rf dist && tsrs -p tsconfig.build.json",
|
|
30
36
|
"typecheck": "tsrs --noEmit"
|
|
31
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@boxxy/contract": "0.1.3",
|
|
40
|
+
"@boxxy/model": "0.1.3",
|
|
41
|
+
"@orpc/client": "2.0.0-beta.50",
|
|
42
|
+
"ts-pattern": "5.9.2",
|
|
43
|
+
"zod": "4.6.7"
|
|
44
|
+
},
|
|
32
45
|
"engines": {
|
|
33
46
|
"bun": ">=1.3",
|
|
34
47
|
"node": ">=24"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { exitCodes } from '../constants.ts';
|
|
3
|
+
import type { CLIResult } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
// the answers that mean the server did not do what the command asked
|
|
6
|
+
const refusalSchema = z.object({ kind: z.enum(['refused', 'rejected', 'fault']) });
|
|
7
|
+
|
|
8
|
+
// The result of a command that prints the server's answer as JSON. A refusal prints the same
|
|
9
|
+
// text and exits with 1, so a script can branch on the exit code.
|
|
10
|
+
export function buildAnswerResult(answer: unknown): CLIResult {
|
|
11
|
+
return {
|
|
12
|
+
exitCode: refusalSchema.safeParse(answer).success ? exitCodes.failed : exitCodes.ok,
|
|
13
|
+
stdout: `${JSON.stringify(answer, null, 2)}\n`,
|
|
14
|
+
stderr: '',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildTextResult(text: string, exitCode: number = exitCodes.ok): CLIResult {
|
|
19
|
+
return { exitCode, stdout: text === '' ? '' : `${text}\n`, stderr: '' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildErrorResult(message: string, exitCode: number = exitCodes.failed): CLIResult {
|
|
23
|
+
return { exitCode, stdout: '', stderr: `${message}\n` };
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { access } from '@boxxy/contract';
|
|
2
|
+
import { envNames } from '../constants.ts';
|
|
3
|
+
import type { Command } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
// The usage line and the summary of each command that starts with the prefix. The usage line is
|
|
6
|
+
// the one the contract's access table holds.
|
|
7
|
+
export function formatHelp(commands: readonly Command[], prefix: readonly string[]): string {
|
|
8
|
+
const lines = commands
|
|
9
|
+
.filter((command) => prefix.every((word, index) => command.path[index] === word))
|
|
10
|
+
.map((command) => `${access[command.procedure].cli}\n ${command.summary}`);
|
|
11
|
+
|
|
12
|
+
return [
|
|
13
|
+
'usage: boxxy <command> [flags]',
|
|
14
|
+
'',
|
|
15
|
+
...lines,
|
|
16
|
+
'',
|
|
17
|
+
`${envNames.url} is the address of the boxxy server.`,
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command, CommandCall } from '../types.ts';
|
|
2
|
+
import { buildAnswerResult } from './build-answer-result.ts';
|
|
3
|
+
|
|
4
|
+
// The `run` of a command that sends one call and prints the server's answer as JSON.
|
|
5
|
+
export function makeAnswerRun(send: (call: CommandCall) => Promise<unknown>): Command['run'] {
|
|
6
|
+
return async (call) => {
|
|
7
|
+
const answer = await send(call);
|
|
8
|
+
|
|
9
|
+
return buildAnswerResult(answer);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import type { Command, CommandArgs } from '../types.ts';
|
|
3
|
+
import { UsageError } from './usage-error.ts';
|
|
4
|
+
|
|
5
|
+
// Reads the words after the command's name through the command's option table. An unknown flag, a
|
|
6
|
+
// flag with no value or a missing word throws a usage error.
|
|
7
|
+
export function parseCommandArgs(command: Command, line: readonly string[]): CommandArgs {
|
|
8
|
+
const parsed = parseLine(command, line);
|
|
9
|
+
const values: Readonly<Record<string, unknown>> = parsed.values;
|
|
10
|
+
const positionals = parsed.positionals;
|
|
11
|
+
|
|
12
|
+
const readStrings = (name: string): string[] => {
|
|
13
|
+
const value = values[name];
|
|
14
|
+
|
|
15
|
+
if (typeof value === 'string') {
|
|
16
|
+
return [value];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!Array.isArray(value)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return value.filter((entry: unknown): entry is string => typeof entry === 'string');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const readString = (name: string): string | undefined => readStrings(name)[0];
|
|
27
|
+
|
|
28
|
+
const readOneOf = <Choice extends string>(
|
|
29
|
+
name: string,
|
|
30
|
+
choices: readonly Choice[],
|
|
31
|
+
): Choice | undefined => {
|
|
32
|
+
const value = readString(name);
|
|
33
|
+
const choice = choices.find((candidate) => candidate === value);
|
|
34
|
+
|
|
35
|
+
if (value !== undefined && choice === undefined) {
|
|
36
|
+
throw new UsageError(`--${name} takes one of: ${choices.join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return choice;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
positional: (index, label) => positionals[index] ?? failMissingWord(label),
|
|
44
|
+
positionalsFrom: (index, label) => {
|
|
45
|
+
const rest = positionals.slice(index);
|
|
46
|
+
|
|
47
|
+
return rest.length === 0 ? failMissingWord(label) : rest;
|
|
48
|
+
},
|
|
49
|
+
string: readString,
|
|
50
|
+
requireString: (name) => readString(name) ?? failMissingFlag(name),
|
|
51
|
+
strings: readStrings,
|
|
52
|
+
integer: (name) => {
|
|
53
|
+
const value = readString(name);
|
|
54
|
+
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!/^\d+$/u.test(value)) {
|
|
60
|
+
throw new UsageError(`--${name} takes a whole number`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Number(value);
|
|
64
|
+
},
|
|
65
|
+
oneOf: readOneOf,
|
|
66
|
+
requireOneOf: (name, choices) => readOneOf(name, choices) ?? failMissingFlag(name),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseLine(command: Command, line: readonly string[]) {
|
|
71
|
+
try {
|
|
72
|
+
return parseArgs({
|
|
73
|
+
args: [...line],
|
|
74
|
+
options: command.options,
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
strict: true,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
|
|
81
|
+
throw new UsageError(message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function failMissingWord(label: string): never {
|
|
86
|
+
throw new UsageError(`missing <${label}>`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function failMissingFlag(name: string): never {
|
|
90
|
+
throw new UsageError(`missing --${name}`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Command } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
export type PickedCommand =
|
|
4
|
+
| { readonly kind: 'command'; readonly command: Command; readonly words: readonly string[] }
|
|
5
|
+
|
|
6
|
+
// the words name no command, or ask for help; `prefix` is the part that names a group
|
|
7
|
+
| { readonly kind: 'help'; readonly prefix: readonly string[] }
|
|
8
|
+
| { readonly kind: 'unknown'; readonly word: string };
|
|
9
|
+
|
|
10
|
+
const helpWords = new Set(['help', '--help', '-h']);
|
|
11
|
+
|
|
12
|
+
// Finds the command that the command line starts with. The rest of the line is its flags and
|
|
13
|
+
// words.
|
|
14
|
+
export function pickCommand(commands: readonly Command[], argv: readonly string[]): PickedCommand {
|
|
15
|
+
const [first] = argv;
|
|
16
|
+
|
|
17
|
+
if (first === undefined || helpWords.has(first)) {
|
|
18
|
+
return { kind: 'help', prefix: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const command = commands.find((candidate) =>
|
|
22
|
+
candidate.path.every((word, index) => argv[index] === word),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (command === undefined) {
|
|
26
|
+
return pickGroup(commands, argv);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const words = argv.slice(command.path.length);
|
|
30
|
+
|
|
31
|
+
return words.some((word) => helpWords.has(word))
|
|
32
|
+
? { kind: 'help', prefix: command.path }
|
|
33
|
+
: { kind: 'command', command, words };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pickGroup(commands: readonly Command[], argv: readonly string[]): PickedCommand {
|
|
37
|
+
const prefix = argv.filter((word) => !helpWords.has(word));
|
|
38
|
+
|
|
39
|
+
const isGroup = commands.some((candidate) =>
|
|
40
|
+
prefix.every((word, index) => candidate.path[index] === word),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return isGroup ? { kind: 'help', prefix } : { kind: 'unknown', word: argv[0] ?? '' };
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ORPCError } from '@orpc/client';
|
|
2
|
+
import { allCommands } from '../commands/all-commands.ts';
|
|
3
|
+
import { exitCodes } from '../constants.ts';
|
|
4
|
+
import { createClient } from '../create-client.ts';
|
|
5
|
+
import type { CLIDeps, CLIResult } from '../types.ts';
|
|
6
|
+
import { buildErrorResult, buildTextResult } from './build-answer-result.ts';
|
|
7
|
+
import { formatHelp } from './format-help.ts';
|
|
8
|
+
import { parseCommandArgs } from './parse-command-args.ts';
|
|
9
|
+
import { pickCommand } from './pick-command.ts';
|
|
10
|
+
import { UsageError } from './usage-error.ts';
|
|
11
|
+
|
|
12
|
+
// Runs one command line and returns what to print and the exit code. It prints and exits nothing,
|
|
13
|
+
// so a test calls it with the server's listener as `deps.send`.
|
|
14
|
+
export async function runCLI(deps: CLIDeps, argv: readonly string[]): Promise<CLIResult> {
|
|
15
|
+
const picked = pickCommand(allCommands, argv);
|
|
16
|
+
|
|
17
|
+
if (picked.kind === 'help') {
|
|
18
|
+
return buildTextResult(formatHelp(allCommands, picked.prefix));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (picked.kind === 'unknown') {
|
|
22
|
+
return buildErrorResult(`unknown command: ${picked.word}`, exitCodes.usage);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const args = parseCommandArgs(picked.command, picked.words);
|
|
27
|
+
|
|
28
|
+
return await picked.command.run({ client: createClient(deps), args, deps });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return describeFailure(error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function describeFailure(error: unknown): CLIResult {
|
|
35
|
+
if (error instanceof UsageError) {
|
|
36
|
+
return buildErrorResult(`error: ${error.message}`, exitCodes.usage);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (error instanceof ORPCError) {
|
|
40
|
+
const data = error.data === undefined ? '' : ` ${JSON.stringify(error.data)}`;
|
|
41
|
+
|
|
42
|
+
return buildErrorResult(`error: ${error.code}: ${error.message}${data}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return buildErrorResult(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Command } from '../types.ts';
|
|
2
|
+
import { attemptCommands } from './attempt-commands.ts';
|
|
3
|
+
import { hookCommands } from './hook-commands.ts';
|
|
4
|
+
import { itemCommands } from './item-commands.ts';
|
|
5
|
+
import { planCommands } from './plan-commands.ts';
|
|
6
|
+
import { relationCommands } from './relation-commands.ts';
|
|
7
|
+
import { reportCommands } from './report-commands.ts';
|
|
8
|
+
import { runnerCommands } from './runner-commands.ts';
|
|
9
|
+
|
|
10
|
+
// One command for each procedure of the contract.
|
|
11
|
+
export const allCommands: readonly Command[] = [
|
|
12
|
+
...itemCommands,
|
|
13
|
+
...relationCommands,
|
|
14
|
+
...planCommands,
|
|
15
|
+
...runnerCommands,
|
|
16
|
+
...reportCommands,
|
|
17
|
+
...attemptCommands,
|
|
18
|
+
...hookCommands,
|
|
19
|
+
];
|