@yolo-labs/yolo-cli 0.23.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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `yolo plan list` — list Plans in the current workspace.
3
+ *
4
+ * The third read-path verb after `plan get` and `plan validate`.
5
+ * Closes the "what's even in this workspace?" gap that operators
6
+ * hit immediately after `yolo context` (no in-CLI way to discover
7
+ * planIds without a curl detour). Maps directly to the substrate's
8
+ * `work.list_plans` route, which is already in
9
+ * `SUBSTRATE_CLI_PLAN_SCOPES`.
10
+ *
11
+ * Two output modes:
12
+ * - default (`--summary`): one-line table per plan with planId,
13
+ * state, version, latestRunId (or `—`), updatedAt (relative ISO
14
+ * date for at-a-glance recency).
15
+ * - `--json`: pretty-printed raw `plans[]` array. For jq /
16
+ * scripted operator pipelines.
17
+ *
18
+ * Filter: `--state <draft|active|archived>` server-side filter
19
+ * (passed as `?state=` query param; renamed from `authoringState`
20
+ * 2026-05-09, item 17). Avoids client-side filtering on workspaces
21
+ * with many plans.
22
+ *
23
+ * Exit codes:
24
+ * - 0 = success (zero plans is also success — empty list, not an error)
25
+ * - 1 = http (e.g., 401 from a token that's been revoked mid-session)
26
+ * - 64 = usage (bad --state, missing env, --workspace mismatch)
27
+ */
28
+ import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
29
+ import { resolveSubstrateContext } from './auth-context.js';
30
+ const ALLOWED_STATES = ['draft', 'active', 'archived'];
31
+ // ─── Public entry ─────────────────────────────────────────────────────────
32
+ export async function runPlanList(options) {
33
+ const env = options.env ?? process.env;
34
+ const format = options.outputFormat ?? 'summary';
35
+ // 1) --state value (validated even though the server also validates,
36
+ // so we can fail fast without a network round-trip).
37
+ if (options.stateFilter !== undefined && !ALLOWED_STATES.includes(options.stateFilter)) {
38
+ return fail('usage', `invalid --state '${options.stateFilter}': must be one of ${ALLOWED_STATES.join(', ')}`);
39
+ }
40
+ // 2) Substrate context
41
+ const auth = resolveSubstrateContext(env);
42
+ if (!auth.ok)
43
+ return fail('auth', auth.message);
44
+ const { sessionId, commonApiUrl, userToken } = auth.context;
45
+ // 3) Mint token
46
+ let mint;
47
+ try {
48
+ mint = await mintSubstrateToken({
49
+ commonApiUrl,
50
+ userToken,
51
+ sessionId,
52
+ scopes: SUBSTRATE_CLI_PLAN_SCOPES,
53
+ fetchImpl: options.fetchImpl,
54
+ });
55
+ }
56
+ catch (err) {
57
+ if (err instanceof WorkClientError) {
58
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
59
+ }
60
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
61
+ }
62
+ // 4) --workspace assertion
63
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
64
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
65
+ }
66
+ // 5) GET /workspaces/<wsId>/plans (with optional ?state=)
67
+ const ctx = {
68
+ commonApiUrl,
69
+ delegatedToken: mint.token,
70
+ fetchImpl: options.fetchImpl,
71
+ };
72
+ const path = options.stateFilter
73
+ ? `/workspaces/${mint.workspaceId}/plans?state=${options.stateFilter}`
74
+ : `/workspaces/${mint.workspaceId}/plans`;
75
+ const response = await authenticatedRequest(ctx, path, { method: 'GET' });
76
+ if (!response.ok) {
77
+ const text = await safeReadText(response);
78
+ return fail('http', `work.list_plans failed: HTTP ${response.status} — ${text}`, {
79
+ status: response.status,
80
+ });
81
+ }
82
+ const json = (await response.json());
83
+ if (!Array.isArray(json.plans)) {
84
+ return fail('http', 'work.list_plans response missing `plans` array');
85
+ }
86
+ return {
87
+ ok: true,
88
+ output: format === 'json'
89
+ ? formatJson(json.plans)
90
+ : formatSummary(json.plans, mint.workspaceId, options.stateFilter),
91
+ plans: json.plans,
92
+ workspaceId: mint.workspaceId,
93
+ };
94
+ }
95
+ // ─── Output formatting ────────────────────────────────────────────────────
96
+ /**
97
+ * One-line header + one row per plan, columns:
98
+ * `planId state v# latestRun updatedAt`. Right-padded so
99
+ * planIds align even when they vary in length. When the list is
100
+ * empty, prints a clear "(no plans)" hint instead of just a header.
101
+ */
102
+ export function formatSummary(plans, workspaceId, stateFilter) {
103
+ const lines = [];
104
+ const filterTag = stateFilter ? ` (filter: ${stateFilter})` : '';
105
+ lines.push(`Plans in workspace ${workspaceId}${filterTag}: ${plans.length}`);
106
+ if (plans.length === 0) {
107
+ lines.push(' (no plans)');
108
+ return lines.join('\n');
109
+ }
110
+ // Column widths from data — keeps short workspaces compact, won't
111
+ // visually break at scale.
112
+ const planIdCol = Math.max(8, ...plans.map((p) => p.planId.length));
113
+ const nameCol = Math.max(4, ...plans.map((p) => p.name.length));
114
+ const header = ` ${'planId'.padEnd(planIdCol)} ${'name'.padEnd(nameCol)} state version latestRun updatedAt`;
115
+ lines.push(header);
116
+ for (const p of plans) {
117
+ const planId = p.planId.padEnd(planIdCol);
118
+ const name = p.name.padEnd(nameCol);
119
+ const state = p.state.padEnd(8);
120
+ const version = `v${p.version}`.padEnd(7);
121
+ const latestRun = (p.latestRunId ?? '—').padEnd(9);
122
+ lines.push(` ${planId} ${name} ${state} ${version} ${latestRun} ${p.updatedAt}`);
123
+ }
124
+ return lines.join('\n');
125
+ }
126
+ function formatJson(plans) {
127
+ return JSON.stringify(plans, null, 2);
128
+ }
129
+ // ─── Helpers ──────────────────────────────────────────────────────────────
130
+ function fail(kind, message, detail) {
131
+ return { ok: false, kind, message, detail };
132
+ }
133
+ function describeError(err) {
134
+ return err instanceof Error ? err.message : String(err);
135
+ }
136
+ async function safeReadText(response) {
137
+ try {
138
+ return await response.text();
139
+ }
140
+ catch {
141
+ return '<no body>';
142
+ }
143
+ }
144
+ export function exitCodeForFailure(kind) {
145
+ switch (kind) {
146
+ case 'usage':
147
+ case 'auth':
148
+ case 'workspace_mismatch':
149
+ return 64;
150
+ case 'http':
151
+ return 1;
152
+ }
153
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `yolo plan open <planId>` — print the webapp Plan-DAG URL for the
3
+ * given Plan in the calling session's workspace.
4
+ *
5
+ * Mints a substrate token to get the workspaceId, calls work.get_plan
6
+ * just to confirm the Plan exists (so a typo'd planId surfaces an
7
+ * `http` error instead of printing a URL that 404s in the browser),
8
+ * then prints the URL. No file I/O, no canonicalization, no Run.
9
+ *
10
+ * Output modes:
11
+ * - default: prints the URL on a single line — handy for piping
12
+ * into clipboard tools or `open`.
13
+ * - `--json`: emits `{ ok, planId, workspaceId, url }`.
14
+ *
15
+ * Exit codes (via the CLI wrapper):
16
+ * - 0 = success
17
+ * - 1 = http (plan not found, etc.)
18
+ * - 64 = usage error (bad planId, missing env trio, --workspace
19
+ * mismatch, derivation failed)
20
+ */
21
+ import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
22
+ import { resolveSubstrateContext } from './auth-context.js';
23
+ import { planDagUrl } from './webapp-url.js';
24
+ const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
25
+ export async function runPlanOpen(options) {
26
+ const env = options.env ?? process.env;
27
+ if (!PLAN_ID_REGEX.test(options.planId)) {
28
+ return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
29
+ }
30
+ const auth = resolveSubstrateContext(env);
31
+ if (!auth.ok)
32
+ return fail('auth', auth.message);
33
+ const { sessionId, commonApiUrl, userToken } = auth.context;
34
+ let mint;
35
+ try {
36
+ mint = await mintSubstrateToken({
37
+ commonApiUrl,
38
+ userToken,
39
+ sessionId,
40
+ scopes: SUBSTRATE_CLI_PLAN_SCOPES,
41
+ fetchImpl: options.fetchImpl,
42
+ });
43
+ }
44
+ catch (err) {
45
+ if (err instanceof WorkClientError) {
46
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
47
+ }
48
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
49
+ }
50
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
51
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
52
+ }
53
+ // Existence check — keeps the CLI honest about typo'd planIds.
54
+ // Cheaper than fetching the full plan: just rely on the HTTP status.
55
+ const ctx = {
56
+ commonApiUrl,
57
+ delegatedToken: mint.token,
58
+ fetchImpl: options.fetchImpl,
59
+ };
60
+ const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, { method: 'GET' });
61
+ if (!response.ok) {
62
+ const text = await safeReadText(response);
63
+ return fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status });
64
+ }
65
+ const url = planDagUrl(env, mint.workspaceId, options.planId);
66
+ if (!url) {
67
+ return fail('usage', 'could not derive webapp URL from YOLO_COMMON_API_URL — set YOLO_WEBAPP_URL to override.');
68
+ }
69
+ return {
70
+ ok: true,
71
+ planId: options.planId,
72
+ workspaceId: mint.workspaceId,
73
+ url,
74
+ output: url,
75
+ };
76
+ }
77
+ function fail(kind, message, detail) {
78
+ return { ok: false, kind, message, detail };
79
+ }
80
+ function describeError(err) {
81
+ return err instanceof Error ? err.message : String(err);
82
+ }
83
+ async function safeReadText(response) {
84
+ try {
85
+ return await response.text();
86
+ }
87
+ catch {
88
+ return '<no body>';
89
+ }
90
+ }
91
+ export function exitCodeForFailure(kind) {
92
+ switch (kind) {
93
+ case 'usage':
94
+ case 'auth':
95
+ case 'workspace_mismatch':
96
+ return 64;
97
+ case 'http':
98
+ return 1;
99
+ }
100
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `yolo plan activate <planId>` / `yolo plan archive <planId>`.
3
+ *
4
+ * Out-of-band Plan-state transitions for plans already in the DB.
5
+ * The file-driven flow can author a plan as `active` from the start
6
+ * (Phase 8d.2 — substrate now accepts `state` on `work.create_plan`),
7
+ * but operators still need a way to:
8
+ * - flip an existing draft to active without rewriting the file
9
+ * - archive a plan that's done its job
10
+ * neither of which has a clean file-level expression.
11
+ *
12
+ * Flow:
13
+ * 1. Validate planId regex.
14
+ * 2. Resolve env trio.
15
+ * 3. Mint a session-bound delegated token
16
+ * (`work.get_plan` + `work.update_plan`, both already in
17
+ * `SUBSTRATE_CLI_PLAN_SCOPES`).
18
+ * 4. Optional `--workspace` sanity-check against the minted
19
+ * workspaceId.
20
+ * 5. GET the current plan to fetch (a) its current state — for the
21
+ * "already in target state" no-op — and (b) its current
22
+ * `version` for the `update_plan(baseVersion, …)` CAS.
23
+ * 6. PATCH with `{ baseVersion, mutations: [{ op: 'set-state',
24
+ * state: <target> }] }`. The substrate's `applyMutations`
25
+ * enforces the transition matrix (draft→active|archived,
26
+ * active→archived; archived is terminal; same-state is allowed
27
+ * and bumps version).
28
+ * 7. Refresh the lockfile entry's `lastImportedVersion` to the
29
+ * new DB version so a subsequent `yolo plan import` is a
30
+ * NO_CHANGE rather than a false-positive DIVERGED. Revision
31
+ * is unchanged because the file didn't change — same
32
+ * revision pointer, new version.
33
+ *
34
+ * Field rename note: the Plan-level state field was `authoringState`
35
+ * before 2026-05-09 and `state` after (item 17 of
36
+ * `docs/SUBSTRATE_IMPROVEMENTS.md`). The substrate accepts only `state`.
37
+ *
38
+ * Exit codes (used by the CLI wrapper):
39
+ * - 0 = success (transition applied OR no-op when already in target)
40
+ * - 1 = http (plan not found, version conflict, transition rejected)
41
+ * - 64 = usage (bad planId, missing env, --workspace mismatch)
42
+ */
43
+ import path from 'node:path';
44
+ import { LockfileError, getEntry, readLockfile, resolveLockfilePath, setEntry, writeLockfile, } from './lockfile.js';
45
+ import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
46
+ import { resolveSubstrateContext } from './auth-context.js';
47
+ const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
48
+ // ─── Public entry ─────────────────────────────────────────────────────────
49
+ export async function runPlanStateTransition(options) {
50
+ const env = options.env ?? process.env;
51
+ const now = options.now ?? (() => new Date().toISOString());
52
+ // 1) planId regex
53
+ if (!PLAN_ID_REGEX.test(options.planId)) {
54
+ return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
55
+ }
56
+ // 2) env trio
57
+ const auth = resolveSubstrateContext(env);
58
+ if (!auth.ok)
59
+ return fail('auth', auth.message);
60
+ const { sessionId, commonApiUrl, userToken } = auth.context;
61
+ // 3) Mint token
62
+ let mint;
63
+ try {
64
+ mint = await mintSubstrateToken({
65
+ commonApiUrl,
66
+ userToken,
67
+ sessionId,
68
+ scopes: SUBSTRATE_CLI_PLAN_SCOPES,
69
+ fetchImpl: options.fetchImpl,
70
+ });
71
+ }
72
+ catch (err) {
73
+ if (err instanceof WorkClientError) {
74
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
75
+ }
76
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
77
+ }
78
+ // 4) --workspace assertion
79
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
80
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
81
+ }
82
+ const ctx = {
83
+ commonApiUrl,
84
+ delegatedToken: mint.token,
85
+ fetchImpl: options.fetchImpl,
86
+ };
87
+ // 5) GET current plan (need version for CAS, state for no-op detection)
88
+ const getResponse = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, { method: 'GET' });
89
+ if (!getResponse.ok) {
90
+ const text = await safeReadText(getResponse);
91
+ return fail('http', `work.get_plan failed: HTTP ${getResponse.status} — ${text}`, { status: getResponse.status });
92
+ }
93
+ const getJson = (await getResponse.json());
94
+ if (!getJson.plan) {
95
+ return fail('http', 'work.get_plan response missing `plan` field');
96
+ }
97
+ const currentState = getJson.plan.state;
98
+ const baseVersion = getJson.plan.version;
99
+ // 6) No-op short-circuit OR PATCH. Either way, we end up with
100
+ // a definitive (state, version) pair that the lockfile must
101
+ // reflect — the substrate's view of the plan is the source
102
+ // of truth for `lastImportedVersion`. Skipping the lockfile
103
+ // refresh on the no-op path was the Codex R1 finding:
104
+ // if the lockfile pointed at a stale version (e.g., the
105
+ // plan was activated outside this CLI between two
106
+ // `yolo plan activate` invocations), retry would return
107
+ // success but leave the divergence to fire on the next
108
+ // `yolo plan import`.
109
+ let finalVersion;
110
+ if (currentState === options.targetState) {
111
+ // No-op: substrate would accept same-state and bump version,
112
+ // but we'd rather not churn the DB on idempotent calls. The
113
+ // GET-response version is what the lockfile must align to.
114
+ finalVersion = baseVersion;
115
+ }
116
+ else {
117
+ const patchResponse = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, {
118
+ method: 'PATCH',
119
+ jsonBody: {
120
+ baseVersion,
121
+ mutations: [{ op: 'set-state', state: options.targetState }],
122
+ },
123
+ });
124
+ if (!patchResponse.ok) {
125
+ const text = await safeReadText(patchResponse);
126
+ return fail('http', `work.update_plan failed: HTTP ${patchResponse.status} — ${text}`, { status: patchResponse.status });
127
+ }
128
+ const patchJson = (await patchResponse.json());
129
+ if (typeof patchJson.version !== 'number') {
130
+ return fail('http', 'work.update_plan response missing version field');
131
+ }
132
+ finalVersion = patchJson.version;
133
+ }
134
+ // 7) Refresh the lockfile entry (if one exists for this
135
+ // workspace/plan). Same on both paths: revision pointer
136
+ // stays put (the file didn't change), version + timestamp
137
+ // realign to the substrate's current view. Best-effort skip
138
+ // when no entry exists (e.g., plan was created via curl,
139
+ // not import).
140
+ const plansDir = options.plansDir ?? path.resolve('.yolo', 'plans');
141
+ const lockfileResult = refreshLockfileEntry({
142
+ plansDir,
143
+ lockfileFlag: options.lockfileFlag,
144
+ workspaceId: mint.workspaceId,
145
+ planId: options.planId,
146
+ newVersion: finalVersion,
147
+ now,
148
+ });
149
+ if (!lockfileResult.ok)
150
+ return lockfileResult.error;
151
+ return {
152
+ ok: true,
153
+ planId: options.planId,
154
+ workspaceId: mint.workspaceId,
155
+ fromState: currentState,
156
+ toState: options.targetState,
157
+ version: finalVersion,
158
+ noop: currentState === options.targetState,
159
+ };
160
+ }
161
+ function refreshLockfileEntry(opts) {
162
+ let lockfilePath;
163
+ let lockfile;
164
+ try {
165
+ lockfilePath = resolveLockfilePath(opts.plansDir, opts.lockfileFlag);
166
+ lockfile = readLockfile(lockfilePath);
167
+ }
168
+ catch (err) {
169
+ if (err instanceof LockfileError) {
170
+ return { ok: false, error: fail('lockfile', err.message, { code: err.code }) };
171
+ }
172
+ return { ok: false, error: fail('lockfile', describeError(err)) };
173
+ }
174
+ const existing = getEntry(lockfile, opts.workspaceId, opts.planId);
175
+ if (!existing) {
176
+ // Best-effort: no entry, no write. The plan was likely
177
+ // created via curl or a different operator's machine.
178
+ return { ok: true };
179
+ }
180
+ const refreshed = {
181
+ lastImportedRevision: existing.lastImportedRevision,
182
+ lastImportedVersion: opts.newVersion,
183
+ lastImportedAt: opts.now(),
184
+ };
185
+ setEntry(lockfile, opts.workspaceId, opts.planId, refreshed);
186
+ try {
187
+ writeLockfile(lockfilePath, lockfile);
188
+ }
189
+ catch (err) {
190
+ if (err instanceof LockfileError) {
191
+ return { ok: false, error: fail('lockfile', err.message, { code: err.code }) };
192
+ }
193
+ return { ok: false, error: fail('lockfile', describeError(err)) };
194
+ }
195
+ return { ok: true };
196
+ }
197
+ // ─── Helpers ──────────────────────────────────────────────────────────────
198
+ function fail(kind, message, detail) {
199
+ return { ok: false, kind, message, detail };
200
+ }
201
+ function describeError(err) {
202
+ return err instanceof Error ? err.message : String(err);
203
+ }
204
+ async function safeReadText(response) {
205
+ try {
206
+ return await response.text();
207
+ }
208
+ catch {
209
+ return '<no body>';
210
+ }
211
+ }
212
+ export function exitCodeForFailure(kind) {
213
+ switch (kind) {
214
+ case 'usage':
215
+ case 'auth':
216
+ case 'workspace_mismatch':
217
+ return 64;
218
+ case 'http':
219
+ case 'lockfile':
220
+ return 1;
221
+ }
222
+ }
223
+ export function formatSuccess(result) {
224
+ if (result.noop) {
225
+ return `OK: plan '${result.planId}' is already ${result.toState} (workspace ${result.workspaceId}, version ${result.version})`;
226
+ }
227
+ return `OK: plan '${result.planId}' transitioned ${result.fromState} → ${result.toState} (workspace ${result.workspaceId}, version ${result.version})`;
228
+ }