@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,61 @@
1
+ /**
2
+ * Webapp URL derivation for CLI link-print.
3
+ *
4
+ * The substrate CLI runs in a container that has `YOLO_COMMON_API_URL`
5
+ * but no first-class webapp URL var. To keep `yolo plan import` / `yolo
6
+ * plan get` able to print a clickable Plan-DAG link without requiring
7
+ * operators to set a new env var, derive the webapp host from the
8
+ * common-api host: prod is `api.yolo.studio` → `yolo.studio`, staging
9
+ * is `api-staging.yolo.studio` → `staging.yolo.studio`. Operators can
10
+ * override via `YOLO_WEBAPP_URL` for local dev / custom deployments.
11
+ *
12
+ * When derivation fails (an unrecognized API URL shape, e.g. a raw
13
+ * localhost port), `deriveWebappUrl` returns null and the caller is
14
+ * expected to skip the URL line — printing a bogus URL would be worse
15
+ * than printing nothing.
16
+ */
17
+ export function deriveWebappUrl(input) {
18
+ if (input.webappUrlOverride && input.webappUrlOverride.length > 0) {
19
+ return stripTrailingSlash(input.webappUrlOverride);
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(input.commonApiUrl);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ const host = parsed.hostname;
29
+ // Recognized prod shape: api.<rest> → <rest>
30
+ if (host.startsWith('api.')) {
31
+ return `${parsed.protocol}//${host.slice('api.'.length)}`;
32
+ }
33
+ // Recognized env-suffixed shape: api-<env>.<rest> → <env>.<rest>
34
+ const envMatch = /^api-([^.]+)\.(.+)$/.exec(host);
35
+ if (envMatch) {
36
+ return `${parsed.protocol}//${envMatch[1]}.${envMatch[2]}`;
37
+ }
38
+ // Unknown shape (raw localhost, IPs, custom hosts). Don't guess.
39
+ return null;
40
+ }
41
+ /**
42
+ * Convenience for the CLI flows: derive the webapp URL from
43
+ * `process.env`-style inputs and format the Plan-DAG path. Returns
44
+ * null when derivation fails so the caller can omit the URL line
45
+ * entirely.
46
+ */
47
+ export function planDagUrl(env, workspaceId, planId) {
48
+ const commonApiUrl = env.YOLO_COMMON_API_URL || env.YOLO_API_URL;
49
+ if (!commonApiUrl)
50
+ return null;
51
+ const base = deriveWebappUrl({
52
+ commonApiUrl,
53
+ webappUrlOverride: env.YOLO_WEBAPP_URL,
54
+ });
55
+ if (!base)
56
+ return null;
57
+ return `${base}/workspaces/${workspaceId}/plans/${planId}`;
58
+ }
59
+ function stripTrailingSlash(s) {
60
+ return s.endsWith('/') ? s.slice(0, -1) : s;
61
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Substrate-CLI work client (Phase 8c.2).
3
+ *
4
+ * Replaces the 8a Group 9 scaffold stub with the real auth flow:
5
+ *
6
+ * 1. `mintSubstrateToken` POSTs to `/internal/mcp/tokens` with
7
+ * `Authorization: Bearer <user JWT>` and the session-bound payload
8
+ * `{ sessionId, agentId: 'substrate-cli', scopes: [...] }`. The endpoint
9
+ * (`internalOrUserAuth`) verifies the JWT's userId owns the session,
10
+ * derives `workspaceId`, and returns a delegated JWT in `result.token`
11
+ * plus `result.claims.workspaceId`. The substrate CLI's lockfile is
12
+ * keyed by that workspaceId — `--workspace` is optional in containers
13
+ * because the session record IS the source of truth.
14
+ *
15
+ * 2. `authenticatedRequest` is the helper used by `yolo plan
16
+ * import/export` (8c.3+) to call `/internal/work/*`. It sends
17
+ * `Authorization: Bearer <delegated>` — the delegated MCP token is the
18
+ * capability (the per-agent allowedScopes check at the work routes is
19
+ * satisfied by the token's claims). The CLI is user-JWT-only; the
20
+ * INTERNAL_API_KEY / X-Internal-Auth path was removed.
21
+ *
22
+ * Both calls take an injectable `fetchImpl` so unit tests can stub
23
+ * the transport without touching real `globalThis.fetch`. In v1 the
24
+ * substrate CLI requires Node 20+, which guarantees `fetch` exists
25
+ * globally — `engines: ">=20"` is pinned in package.json.
26
+ *
27
+ * Substrate-CLI v1 scopes (capped per agents.json `substrate-cli`):
28
+ * - Plan authoring: work.create_plan, work.update_plan,
29
+ * work.get_plan, work.list_plans
30
+ * - Run lifecycle: work.start_run, work.get_run,
31
+ * work.list_runs, work.pause_run, work.resume_run,
32
+ * work.cancel_run, work.transfer_run_operator
33
+ * - Artifact reads: work.get_artifact, work.list_artifacts, work.transfer_run_operator
34
+ *
35
+ * Run-lifecycle implication: `work.start_run` binds the calling agent
36
+ * as the Run's Operator (route handler, Phase 4/6 R4). So a Run started
37
+ * via `yolo run start` has `operatorAgentId === 'substrate-cli'`, and
38
+ * only the substrate CLI can pause/resume/cancel it via MCP. The
39
+ * natural handoff loop is `yolo run transfer <runId> --to claude` —
40
+ * substrate-cli is the current Operator, claude/codex is an
41
+ * Operator-tier target, route's R4 self-transfer path applies.
42
+ * Substrate-cli is NOT itself Operator-tier (per
43
+ * `OPERATOR_TIER_AGENT_IDS` in operator-binding.ts), so `--to
44
+ * substrate-cli` is rejected by the route's `isValidOperatorTarget`
45
+ * check.
46
+ */
47
+ export const SUBSTRATE_CLI_AGENT_ID = 'substrate-cli';
48
+ export const SUBSTRATE_CLI_PLAN_SCOPES = [
49
+ 'work.create_plan',
50
+ 'work.update_plan',
51
+ 'work.get_plan',
52
+ 'work.list_plans',
53
+ ];
54
+ export const SUBSTRATE_CLI_RUN_SCOPES = [
55
+ 'work.start_run',
56
+ 'work.get_run',
57
+ 'work.list_runs',
58
+ 'work.pause_run',
59
+ 'work.resume_run',
60
+ 'work.cancel_run',
61
+ 'work.transfer_run_operator',
62
+ ];
63
+ export const SUBSTRATE_CLI_ARTIFACT_SCOPES = [
64
+ 'work.get_artifact',
65
+ 'work.list_artifacts',
66
+ ];
67
+ export class WorkClientError extends Error {
68
+ status;
69
+ code;
70
+ constructor(message, status, code) {
71
+ super(message);
72
+ this.name = 'WorkClientError';
73
+ this.status = status;
74
+ this.code = code;
75
+ }
76
+ }
77
+ /**
78
+ * Mint a session-bound delegated token for the substrate CLI.
79
+ * Server-derived workspaceId comes back in `claims.workspaceId`.
80
+ *
81
+ * Throws `WorkClientError` with `status` and `code` from the
82
+ * common-api error envelope (`{ error, code }`); networks failures
83
+ * surface as plain `Error` from the underlying fetch.
84
+ */
85
+ export async function mintSubstrateToken(options) {
86
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
87
+ if (!fetchImpl) {
88
+ throw new WorkClientError('fetch is not available; substrate CLI requires Node 20+', 0, 'INTERNAL');
89
+ }
90
+ if (!options.userToken) {
91
+ throw new WorkClientError('no user token available to mint a substrate token', 0, 'INTERNAL');
92
+ }
93
+ const url = `${stripTrailingSlash(options.commonApiUrl)}/internal/mcp/tokens`;
94
+ const response = await fetchImpl(url, {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ Authorization: `Bearer ${options.userToken}`,
99
+ },
100
+ body: JSON.stringify({
101
+ sessionId: options.sessionId,
102
+ agentId: SUBSTRATE_CLI_AGENT_ID,
103
+ scopes: options.scopes,
104
+ }),
105
+ });
106
+ if (!response.ok) {
107
+ let parsed = {};
108
+ try {
109
+ parsed = (await response.json());
110
+ }
111
+ catch {
112
+ // body wasn't JSON — fall through to a generic message
113
+ }
114
+ throw new WorkClientError(parsed.error ?? `mint failed: HTTP ${response.status}`, response.status, parsed.code ?? 'INTERNAL');
115
+ }
116
+ const json = (await response.json());
117
+ if (typeof json.token !== 'string' || json.token.length === 0) {
118
+ throw new WorkClientError('mint response missing string `token`', 502, 'INTERNAL');
119
+ }
120
+ if (typeof json.expiresAt !== 'string' || json.expiresAt.length === 0) {
121
+ throw new WorkClientError('mint response missing string `expiresAt`', 502, 'INTERNAL');
122
+ }
123
+ if (typeof json.jti !== 'string' || json.jti.length === 0) {
124
+ throw new WorkClientError('mint response missing string `jti`', 502, 'INTERNAL');
125
+ }
126
+ const workspaceId = json.claims?.workspaceId;
127
+ if (typeof workspaceId !== 'string' || workspaceId.length === 0) {
128
+ throw new WorkClientError('mint response missing string `claims.workspaceId`', 502, 'INTERNAL');
129
+ }
130
+ const userId = json.claims?.userId;
131
+ if (typeof userId !== 'string' || userId.length === 0) {
132
+ throw new WorkClientError('mint response missing string `claims.userId`', 502, 'INTERNAL');
133
+ }
134
+ return {
135
+ token: json.token,
136
+ expiresAt: json.expiresAt,
137
+ workspaceId,
138
+ userId,
139
+ jti: json.jti,
140
+ };
141
+ }
142
+ /**
143
+ * Make an authenticated request to `/internal/work/*` with
144
+ * `Authorization: Bearer <delegated token>` (the delegated MCP token is the
145
+ * capability; requireMcpAuth accepts it without any service header). `workPath`
146
+ * is appended verbatim to `${commonApiUrl}/internal/work` — start it with a
147
+ * slash, e.g., `/workspaces/${workspaceId}/plans/${planId}`.
148
+ */
149
+ export async function authenticatedRequest(options, workPath, init = {}) {
150
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
151
+ if (!fetchImpl) {
152
+ throw new WorkClientError('fetch is not available; substrate CLI requires Node 20+', 0, 'INTERNAL');
153
+ }
154
+ const url = `${stripTrailingSlash(options.commonApiUrl)}/internal/work${workPath}`;
155
+ const headers = {
156
+ ...(init.headers ?? {}),
157
+ Authorization: `Bearer ${options.delegatedToken}`,
158
+ };
159
+ const fetchInit = {
160
+ method: init.method ?? 'GET',
161
+ headers,
162
+ };
163
+ if (init.jsonBody !== undefined) {
164
+ headers['Content-Type'] = 'application/json';
165
+ fetchInit.body = JSON.stringify(init.jsonBody);
166
+ }
167
+ return fetchImpl(url, fetchInit);
168
+ }
169
+ export async function userRouteRequest(options, routePath, init = {}) {
170
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
171
+ if (!fetchImpl) {
172
+ throw new WorkClientError('fetch is not available; substrate CLI requires Node 20+', 0, 'INTERNAL');
173
+ }
174
+ if (!options.userToken) {
175
+ throw new WorkClientError('no user token available for user-route request', 0, 'INTERNAL');
176
+ }
177
+ const authHeaders = { Authorization: `Bearer ${options.userToken}` };
178
+ const url = `${stripTrailingSlash(options.commonApiUrl)}/v1${routePath}`;
179
+ const headers = {
180
+ ...(init.headers ?? {}),
181
+ ...authHeaders,
182
+ };
183
+ const fetchInit = {
184
+ method: init.method ?? 'GET',
185
+ headers,
186
+ };
187
+ if (init.jsonBody !== undefined) {
188
+ headers['Content-Type'] = 'application/json';
189
+ fetchInit.body = JSON.stringify(init.jsonBody);
190
+ }
191
+ return fetchImpl(url, fetchInit);
192
+ }
193
+ function stripTrailingSlash(url) {
194
+ return url.endsWith('/') ? url.slice(0, -1) : url;
195
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@yolo-labs/yolo-cli",
3
+ "version": "0.23.0",
4
+ "description": "YOLO Studio substrate CLI — owns yolo plan/workspace/artifact substrate-tooling commands. Distinct from yolo-code (the agent CLI) and yolo-router (the LLM gateway client).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "yolo": "dist/cli.js"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "dist/**/*.js",
15
+ "!dist/**/*.test.js",
16
+ "dist/**/*.mjs",
17
+ "dist/**/*.json",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json && cp src/plan-frontmatter.schema.json dist/ && node scripts/build-vendored-flexdb.mjs",
22
+ "test": "npm run build && node --test --test-reporter=spec dist/*.test.js",
23
+ "clean": "rm -rf dist",
24
+ "prepublishOnly": "npm test"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "dependencies": {
30
+ "ajv": "^6.12.6",
31
+ "esbuild": "^0.24.0",
32
+ "js-yaml": "^4.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.4.0",
36
+ "@types/node": "^20.0.0",
37
+ "@types/js-yaml": "^4.0.9"
38
+ }
39
+ }