@shipd-ai/cli 0.1.0

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 ADDED
@@ -0,0 +1,133 @@
1
+ # Shipd CLI (`@shipd-ai/cli`)
2
+
3
+ `shipd` is the admin command line for Shipd quests. Point it at a Harbor task
4
+ directory (or an existing task) and it runs the quest's checks on the quest's
5
+ own infrastructure, then returns the same structured output the product shows.
6
+ Olympus is the first quest; every quest that implements the small
7
+ `adhocChecks:*` contract works the same way.
8
+
9
+ Admins only: every quest entrypoint checks the caller's effective admin role.
10
+
11
+ ## Install and log in
12
+
13
+ Admin docs, the supported checks, and a copy-paste setup prompt for coding agents live at https://shipd.ai/admin/cli (admins only; direct link to the prompt: https://shipd.ai/admin/cli#agent-setup-prompt).
14
+
15
+ ```bash
16
+ npm install -g @shipd-ai/cli
17
+ shipd login # opens https://shipd.ai/quests/olympus/cli-auth
18
+ shipd whoami
19
+ ```
20
+
21
+ `login` opens the token page in your browser; paste the token back into the
22
+ terminal. It is stored at `~/.shipd/credentials.json` (mode 0600). The token is
23
+ personal: never commit it, never pass it on a command line. `shipd logout`
24
+ deletes it.
25
+
26
+ ## Three input modes
27
+
28
+ | Mode | Command | What happens | Writes to production |
29
+ | --- | --- | --- | --- |
30
+ | Harbor, stateless (default) | `shipd checks run <harbor-dir> --check …` | Adapts the bundle, runs the checks on the quest's infrastructure, returns their normal output. | Nothing. |
31
+ | Harbor, create | `shipd checks run <harbor-dir> --create --check …` | Turns the bundle into a real draft task under your account (every authoring guard applies), then runs the checks live on it. | A draft task + version and the normal job rows. Not submitted for review. |
32
+ | Existing task | `shipd checks run --task <problemId> --check …` | Runs the checks live on the task's latest version. | The normal job rows; the version's visible results are re-pointed. |
33
+
34
+ Both live modes print a `Writes to production:` line before anything is
35
+ dispatched; `--create` prints the new task URL as soon as the draft exists.
36
+ Checks cost real compute: use `--dry-run` first and confirm `--all` with a
37
+ human when an agent is driving.
38
+
39
+ ## Harbor layout
40
+
41
+ Only these paths are read; everything else in the directory is ignored.
42
+
43
+ | Path | Used as | Required for |
44
+ | --- | --- | --- |
45
+ | `task.toml` | `[metadata]` (`problem_title`, `category`, `language`, `difficulty`, `repository_url`, `base_commit_hash`), `[environment].docker_image`, `[verifier].timeout_sec` | all |
46
+ | `instruction.md` | task description | description-bearing checks |
47
+ | `environment/Dockerfile` | sandbox image | sandbox checks without `docker_image` |
48
+ | `tests/test.patch` | test patch | every check except `verifyBuild` |
49
+ | `tests/test.sh` | carried for future native runs | unused by v1 checks |
50
+ | `solution/solution.patch` | solution patch | solution-bearing checks |
51
+ | `solution/solve.sh` | carried for future native runs | unused by v1 checks |
52
+
53
+ A missing or unparsable `task.toml` is a bundle error (exit 2). The seven
54
+ files may total at most 4 MiB. `--repo`, `--commit`, `--title`, `--category`,
55
+ `--language` and `--difficulty` override the `task.toml` metadata, which is how
56
+ a non-Olympus bundle fills in what it lacks.
57
+
58
+ ## Commands
59
+
60
+ ```bash
61
+ shipd checks list --quest olympus
62
+ shipd checks run ./task --all --dry-run # eligibility + adapted inputs, spends nothing
63
+ shipd checks run ./task --check verifyBuild,descriptionQuality
64
+ shipd checks run ./task --all --json --out report.json
65
+ shipd checks run ./task --create --check autoReview # creates a draft, runs live
66
+ shipd checks run --task <problemId> --check scopeGate # runs live on an existing task
67
+ shipd checks run ./task --check verifyBuild --no-wait # prints handles as JSON lines
68
+ shipd checks status '<handle json>' # or: shipd checks status report.json
69
+ shipd checks cancel '<handle json>' # or: shipd checks cancel report.json
70
+ ```
71
+
72
+ `run` options: `--quest` (default `olympus`), `--check a,b` or `--all`,
73
+ `--dry-run`, `--no-wait`, `--json`, `--out <file>`, metadata overrides,
74
+ `--model` and `--effort low|medium|high|xhigh` (honored where the production
75
+ dispatcher honors them), `--poll <seconds>` (default 15), `--timeout <minutes>`
76
+ per step (default 180, the longest worker timeout).
77
+
78
+ Checks whose required bundle parts are missing are reported as `skipped` with
79
+ the missing list. With `--all` that is informational; with `--check` it fails
80
+ the run. Multi-step checks (for example `autoReview`) run as a step plan the
81
+ quest describes: independent steps in parallel, each later step fed the
82
+ earlier outputs, a failed step marking its dependents `skipped: upstream
83
+ failed`. Prechecks (`precheck:<stage>`) run synchronously.
84
+
85
+ Ctrl-C asks whether to cancel the in-flight steps; a second Ctrl-C leaves them
86
+ running. Handles stay in the JSON report, so `checks status` and
87
+ `checks cancel` keep working on them afterwards.
88
+
89
+ ## Exit codes
90
+
91
+ | Code | Meaning |
92
+ | --- | --- |
93
+ | 0 | every run check passed or warned |
94
+ | 1 | a check failed or errored (or an explicitly requested check was skipped) |
95
+ | 2 | usage, auth, or bundle error (nothing was dispatched) |
96
+ | 3 | cancelled |
97
+
98
+ ## Report
99
+
100
+ `--json` and `--out` produce the same document: `quest`, `mode`, `source`
101
+ (bundle directory and `inputHash`, or the task ids, title and URL), a top-level
102
+ `taskUrl` in live modes, one entry per
103
+ check with `status`, `verdict`, `output`, `error`, `durationMs`, and `steps`
104
+ (each step's handle and output), plus `prechecks` keyed by stage id.
105
+
106
+ ## Environment
107
+
108
+ | Variable | Effect |
109
+ | --- | --- |
110
+ | `SHIPD_<QUEST>_URL` | Override the quest's base URL (for example `SHIPD_OLYMPUS_URL`). |
111
+ | `SHIPD_<QUEST>_CONVEX_URL` | Skip `/api/cli/config` and talk to this Convex deployment. |
112
+ | `SHIPD_HOME` | Credentials and caches directory (default `~/.shipd`). |
113
+ | `SHIPD_NO_UPDATE_CHECK=1` | Skip the version check. |
114
+ | `NODE_ENV=development` | Use the localhost quest URLs. |
115
+
116
+ ## Developer notes
117
+
118
+ ```bash
119
+ pnpm --dir packages/shipd-cli typecheck
120
+ pnpm --dir packages/shipd-cli test
121
+ pnpm --dir packages/shipd-cli build
122
+ pnpm --dir packages/shipd-cli cli checks list --quest olympus # runs src/ with tsx
123
+ NODE_ENV=development pnpm --dir packages/shipd-cli cli checks list # against a local quest
124
+ ```
125
+
126
+ The CLI contains no quest logic. It calls `adhocChecks:*` by name through
127
+ Convex's `anyApi`; the shared argument and return shapes live in
128
+ `src/contract.ts`, and each quest mirrors them with validators. The plan
129
+ executor (`src/plan.ts`) and bundle reader (`src/harbor.ts`) are unit-tested
130
+ against a fake quest and a fixture bundle in `test/`.
131
+
132
+ Publishing: `pnpm --dir packages/shipd-cli build && npm publish --access public`
133
+ from an account in the `@shipd-ai` npm org.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export declare function shipdDir(): string;
2
+ export interface DecodedIdentity {
3
+ sub: string;
4
+ email: string;
5
+ username: string;
6
+ name: string;
7
+ picture: string;
8
+ exp: number;
9
+ }
10
+ /**
11
+ * Decode the JWT payload without verifying the signature. The quest's Convex
12
+ * deployment verifies it on every request; the CLI only reads the payload for
13
+ * display (name, email) and the local expiry check.
14
+ */
15
+ export declare function decodeJwtPayload(token: string): DecodedIdentity;
16
+ export declare function ensureShipdDir(): string;
17
+ export declare function saveCredentials(token: string): DecodedIdentity;
18
+ export declare function loadCredentials(): {
19
+ token: string;
20
+ identity: DecodedIdentity;
21
+ } | null;
22
+ export declare function clearCredentials(): boolean;
23
+ export declare function requireAuth(): {
24
+ token: string;
25
+ identity: DecodedIdentity;
26
+ };
package/dist/auth.js ADDED
@@ -0,0 +1,75 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { AuthError } from "./errors.js";
5
+ export function shipdDir() {
6
+ return resolve(process.env.SHIPD_HOME ?? resolve(homedir(), ".shipd"));
7
+ }
8
+ function credentialsPath() {
9
+ return resolve(shipdDir(), "credentials.json");
10
+ }
11
+ /**
12
+ * Decode the JWT payload without verifying the signature. The quest's Convex
13
+ * deployment verifies it on every request; the CLI only reads the payload for
14
+ * display (name, email) and the local expiry check.
15
+ */
16
+ export function decodeJwtPayload(token) {
17
+ const parts = token.split(".");
18
+ if (parts.length !== 3) {
19
+ throw new AuthError("Invalid token: expected a JWT with three segments.");
20
+ }
21
+ const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
22
+ const decoded = JSON.parse(payload);
23
+ if (typeof decoded.exp !== "number" || typeof decoded.sub !== "string") {
24
+ throw new AuthError("Invalid token: payload is missing sub or exp.");
25
+ }
26
+ return decoded;
27
+ }
28
+ export function ensureShipdDir() {
29
+ const dir = shipdDir();
30
+ if (!existsSync(dir)) {
31
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ }
33
+ return dir;
34
+ }
35
+ export function saveCredentials(token) {
36
+ const identity = decodeJwtPayload(token);
37
+ const expiresAt = new Date(identity.exp * 1000).toISOString();
38
+ ensureShipdDir();
39
+ const data = { token, expiresAt };
40
+ writeFileSync(credentialsPath(), `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
41
+ return identity;
42
+ }
43
+ export function loadCredentials() {
44
+ const path = credentialsPath();
45
+ if (!existsSync(path))
46
+ return null;
47
+ try {
48
+ const stored = JSON.parse(readFileSync(path, "utf-8"));
49
+ if (!stored.token)
50
+ return null;
51
+ const identity = decodeJwtPayload(stored.token);
52
+ const now = Math.floor(Date.now() / 1000);
53
+ if (identity.exp <= now)
54
+ return null;
55
+ return { token: stored.token, identity };
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function clearCredentials() {
62
+ const path = credentialsPath();
63
+ if (existsSync(path)) {
64
+ unlinkSync(path);
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ export function requireAuth() {
70
+ const creds = loadCredentials();
71
+ if (!creds) {
72
+ throw new AuthError("Not logged in or the token has expired. Run: shipd login");
73
+ }
74
+ return creds;
75
+ }
@@ -0,0 +1,51 @@
1
+ import type { AdaptResult, Catalog, Handle, HarborBundle, LiveStatus, Options, PrecheckResult, StartStepResult, StepStatus, TaskRef } from "./contract.js";
2
+ /**
3
+ * Everything the plan executor needs from a quest. `QuestClient` implements it
4
+ * over Convex; tests implement it with a fake.
5
+ */
6
+ export interface QuestApi {
7
+ readonly quest: string;
8
+ listChecks(): Promise<Catalog>;
9
+ adapt(bundle: HarborBundle): Promise<AdaptResult>;
10
+ runPrecheckStage(bundle: HarborBundle, stageId: string): Promise<PrecheckResult>;
11
+ startStep(bundle: HarborBundle, checkKey: string, stepId: string, priorOutputs: Record<string, unknown>, options?: Options): Promise<StartStepResult>;
12
+ getStep(handle: Handle): Promise<StepStatus>;
13
+ cancelStep(handle: Handle): Promise<{
14
+ cancelled: boolean;
15
+ }>;
16
+ fetchArtifact(handle: Handle, key: string): Promise<string | null>;
17
+ createFromHarbor(bundle: HarborBundle): Promise<TaskRef>;
18
+ resolveTask(problemId: string): Promise<TaskRef | null>;
19
+ runLiveCheck(versionId: string, problemId: string, checkKey: string, options?: Options): Promise<{
20
+ started: boolean;
21
+ detail?: string;
22
+ }>;
23
+ getLiveCheck(versionId: string, checkKey: string): Promise<LiveStatus>;
24
+ nudgeSynthesis(versionId: string): Promise<null>;
25
+ }
26
+ /** The bundle as sent over the wire: `dir`, `metadata`, `files` and nothing else. */
27
+ export declare function toWireBundle(bundle: HarborBundle): HarborBundle;
28
+ export declare class QuestClient implements QuestApi {
29
+ readonly quest: string;
30
+ private readonly convex;
31
+ constructor(quest: string, convexUrl: string, token: string);
32
+ listChecks(): Promise<Catalog>;
33
+ adapt(bundle: HarborBundle): Promise<AdaptResult>;
34
+ runPrecheckStage(bundle: HarborBundle, stageId: string): Promise<PrecheckResult>;
35
+ startStep(bundle: HarborBundle, checkKey: string, stepId: string, priorOutputs: Record<string, unknown>, options?: Options): Promise<StartStepResult>;
36
+ getStep(handle: Handle): Promise<StepStatus>;
37
+ cancelStep(handle: Handle): Promise<{
38
+ cancelled: boolean;
39
+ }>;
40
+ fetchArtifact(handle: Handle, key: string): Promise<string | null>;
41
+ createFromHarbor(bundle: HarborBundle): Promise<TaskRef>;
42
+ resolveTask(problemId: string): Promise<TaskRef | null>;
43
+ runLiveCheck(versionId: string, problemId: string, checkKey: string, options?: Options): Promise<{
44
+ started: boolean;
45
+ detail?: string;
46
+ }>;
47
+ getLiveCheck(versionId: string, checkKey: string): Promise<LiveStatus>;
48
+ nudgeSynthesis(versionId: string): Promise<null>;
49
+ }
50
+ /** Build an authenticated client for a quest, or throw an AuthError/UsageError. */
51
+ export declare function connectQuest(quest: string): Promise<QuestClient>;
package/dist/client.js ADDED
@@ -0,0 +1,70 @@
1
+ import { ConvexHttpClient } from "convex/browser";
2
+ import { anyApi } from "convex/server";
3
+ import { requireAuth } from "./auth.js";
4
+ import { questConvexUrl } from "./quests.js";
5
+ /** The bundle as sent over the wire: `dir`, `metadata`, `files` and nothing else. */
6
+ export function toWireBundle(bundle) {
7
+ return { dir: bundle.dir, metadata: bundle.metadata, files: bundle.files };
8
+ }
9
+ function withOptions(args, options) {
10
+ return options && Object.keys(options).length > 0 ? { ...args, options } : args;
11
+ }
12
+ // Functions are addressed by name through `anyApi` so the CLI has no
13
+ // dependency on any quest's generated API at typecheck time.
14
+ const fns = anyApi.adhocChecks;
15
+ export class QuestClient {
16
+ quest;
17
+ convex;
18
+ constructor(quest, convexUrl, token) {
19
+ this.quest = quest;
20
+ this.convex = new ConvexHttpClient(convexUrl);
21
+ this.convex.setAuth(token);
22
+ }
23
+ listChecks() {
24
+ return this.convex.query(fns.listChecks, {});
25
+ }
26
+ adapt(bundle) {
27
+ return this.convex.query(fns.adapt, { bundle: toWireBundle(bundle) });
28
+ }
29
+ runPrecheckStage(bundle, stageId) {
30
+ return this.convex.action(fns.runPrecheckStage, {
31
+ bundle: toWireBundle(bundle),
32
+ stageId,
33
+ });
34
+ }
35
+ startStep(bundle, checkKey, stepId, priorOutputs, options) {
36
+ return this.convex.action(fns.startStep, withOptions({ bundle: toWireBundle(bundle), checkKey, stepId, priorOutputs }, options));
37
+ }
38
+ getStep(handle) {
39
+ return this.convex.action(fns.getStep, { handle });
40
+ }
41
+ cancelStep(handle) {
42
+ return this.convex.action(fns.cancelStep, { handle });
43
+ }
44
+ fetchArtifact(handle, key) {
45
+ return this.convex.action(fns.fetchArtifact, { handle, key });
46
+ }
47
+ createFromHarbor(bundle) {
48
+ return this.convex.action(fns.createFromHarbor, {
49
+ bundle: toWireBundle(bundle),
50
+ });
51
+ }
52
+ resolveTask(problemId) {
53
+ return this.convex.query(fns.resolveTask, { problemId });
54
+ }
55
+ runLiveCheck(versionId, problemId, checkKey, options) {
56
+ return this.convex.action(fns.runLiveCheck, withOptions({ versionId, problemId, checkKey }, options));
57
+ }
58
+ getLiveCheck(versionId, checkKey) {
59
+ return this.convex.query(fns.getLiveCheck, { versionId, checkKey });
60
+ }
61
+ nudgeSynthesis(versionId) {
62
+ return this.convex.action(fns.nudgeSynthesis, { versionId });
63
+ }
64
+ }
65
+ /** Build an authenticated client for a quest, or throw an AuthError/UsageError. */
66
+ export async function connectQuest(quest) {
67
+ const { token } = requireAuth();
68
+ const convexUrl = await questConvexUrl(quest);
69
+ return new QuestClient(quest, convexUrl, token);
70
+ }
@@ -0,0 +1,22 @@
1
+ export declare const login: import("citty").CommandDef<{
2
+ readonly token: {
3
+ readonly type: "string";
4
+ readonly description: "Paste the token directly instead of prompting";
5
+ };
6
+ readonly "no-browser": {
7
+ readonly type: "boolean";
8
+ readonly description: "Print the login URL instead of opening it";
9
+ };
10
+ readonly quest: {
11
+ readonly type: "string";
12
+ readonly description: "Quest whose login page to open (default: olympus)";
13
+ readonly default: "olympus";
14
+ };
15
+ }>;
16
+ export declare const logout: import("citty").CommandDef<import("citty").ArgsDef>;
17
+ export declare const whoami: import("citty").CommandDef<{
18
+ readonly json: {
19
+ readonly type: "boolean";
20
+ readonly description: "Output as JSON";
21
+ };
22
+ }>;
@@ -0,0 +1,109 @@
1
+ import { exec, execFile } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { defineCommand } from "citty";
4
+ import { clearCredentials, loadCredentials, saveCredentials } from "../auth.js";
5
+ import { UsageError } from "../errors.js";
6
+ import { printJson } from "../format.js";
7
+ import { DEFAULT_QUEST, questAuthUrl } from "../quests.js";
8
+ function openBrowser(url) {
9
+ if (process.platform === "win32") {
10
+ // `start` is a cmd.exe built-in, not a standalone executable.
11
+ exec(`start "" "${url.replace(/"/g, '\\"')}"`, () => { });
12
+ }
13
+ else {
14
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
15
+ execFile(cmd, [url], () => { });
16
+ }
17
+ }
18
+ function prompt(question) {
19
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
20
+ return new Promise((resolve) => {
21
+ rl.question(question, (answer) => {
22
+ rl.close();
23
+ resolve(answer.trim());
24
+ });
25
+ });
26
+ }
27
+ const questArg = {
28
+ quest: {
29
+ type: "string",
30
+ description: `Quest whose login page to open (default: ${DEFAULT_QUEST})`,
31
+ default: DEFAULT_QUEST,
32
+ },
33
+ };
34
+ export const login = defineCommand({
35
+ meta: { name: "login", description: "Sign in via the browser and store the CLI token" },
36
+ args: {
37
+ ...questArg,
38
+ token: { type: "string", description: "Paste the token directly instead of prompting" },
39
+ "no-browser": { type: "boolean", description: "Print the login URL instead of opening it" },
40
+ },
41
+ run: async ({ args }) => {
42
+ const authUrl = questAuthUrl(args.quest);
43
+ let token = args.token?.trim();
44
+ if (!token) {
45
+ if (args["no-browser"]) {
46
+ console.log(`\n Open this page and copy the token:\n ${authUrl}\n`);
47
+ }
48
+ else {
49
+ console.log(`\n Opening browser...\n ${authUrl}\n`);
50
+ openBrowser(authUrl);
51
+ }
52
+ token = await prompt(" Paste your token: ");
53
+ }
54
+ if (!token)
55
+ throw new UsageError("No token provided.");
56
+ const identity = saveCredentials(token);
57
+ const expiresAt = new Date(identity.exp * 1000).toLocaleDateString();
58
+ console.log(`\n Logged in as ${identity.name || identity.email || identity.sub}`);
59
+ if (identity.username)
60
+ console.log(` Username: @${identity.username}`);
61
+ console.log(` Expires: ${expiresAt}\n`);
62
+ },
63
+ });
64
+ export const logout = defineCommand({
65
+ meta: { name: "logout", description: "Delete the stored CLI token" },
66
+ run: async () => {
67
+ const removed = clearCredentials();
68
+ console.log(removed ? " Logged out." : " Not logged in.");
69
+ },
70
+ });
71
+ export const whoami = defineCommand({
72
+ meta: { name: "whoami", description: "Show who the stored token belongs to" },
73
+ args: {
74
+ json: { type: "boolean", description: "Output as JSON" },
75
+ },
76
+ run: async ({ args }) => {
77
+ const creds = loadCredentials();
78
+ if (!creds) {
79
+ if (args.json) {
80
+ printJson({ authenticated: false });
81
+ }
82
+ else {
83
+ console.log(" Not logged in. Run: shipd login");
84
+ }
85
+ process.exitCode = 2;
86
+ return;
87
+ }
88
+ const { identity } = creds;
89
+ const expiresAt = new Date(identity.exp * 1000);
90
+ const daysLeft = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
91
+ if (args.json) {
92
+ printJson({
93
+ authenticated: true,
94
+ userId: identity.sub,
95
+ email: identity.email,
96
+ username: identity.username,
97
+ name: identity.name,
98
+ expiresAt: expiresAt.toISOString(),
99
+ daysLeft,
100
+ });
101
+ return;
102
+ }
103
+ console.log(` Logged in as ${identity.name || identity.email || identity.sub}`);
104
+ if (identity.username)
105
+ console.log(` Username: @${identity.username}`);
106
+ console.log(` User ID: ${identity.sub}`);
107
+ console.log(` Expires: ${expiresAt.toLocaleDateString()} (${daysLeft}d)`);
108
+ },
109
+ });
@@ -0,0 +1,12 @@
1
+ import { type Catalog, type CheckDef, type TaskRef } from "../contract.js";
2
+ import { type Report } from "../report.js";
3
+ /** "a + b → c": steps grouped by dependency depth. */
4
+ export declare function formatSteps(check: CheckDef): string;
5
+ export declare function selectChecks(catalog: Catalog, explicit: string | undefined, all: boolean): {
6
+ checks: CheckDef[];
7
+ selection: Report["selection"];
8
+ };
9
+ /** Lines for `--dry-run --task`: what would be dispatched, without dispatching. */
10
+ export declare function dryRunTaskLines(task: TaskRef, checks: CheckDef[]): string[];
11
+ declare const _default: import("citty").CommandDef<import("citty").ArgsDef>;
12
+ export default _default;