@oxy-hq/cli 0.2.0 → 0.5.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 CHANGED
@@ -1,163 +1,507 @@
1
- # `@oxy-hq/cli` — `oxyc`
1
+ # `@oxy-hq/cli`
2
2
 
3
- The Oxy CLI. Two halves under one command:
3
+ `oxyc` — a `gh api`-shaped client for the Oxy HTTP API, plus the tooling that
4
+ manages customer workspace repos.
4
5
 
5
- - **Talk to the API** — a `gh api`-shaped client, with discovery, so a caller
6
- with a token and nothing else can find and call any endpoint.
7
- - **Work on a customer account** — which customer, where their repo is, keeping
8
- it in line with the template, and scoping a Claude session to them.
6
+ - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `assume`, `oltp`
7
+ - **Customer workspaces** — `list`, `new`, `import`, `doctor`, `update`, `adopt`, `launch`
8
+ - **Custom apps** — `publish`, `init-ci`, `proxy`
9
+ - **Checks** — `checks run`
10
+ - **Development** — `validate`, `mcp`, `guide`, `skills`
9
11
 
10
- They are one tool because they share one answer to "who is this about": stand
11
- in a customer's repo and `oxyc api {org}/workspaces` needs no ids typed.
12
+ ## Install
12
13
 
13
- > **Not on npm yet.** `@oxy-hq/cli` is deliberately absent from the publish
14
- > workflow until npm Trusted Publishing is configured for it, so `npx
15
- > @oxy-hq/cli` and `pnpm add -g @oxy-hq/cli` 404 today. Install from a
16
- > checkout:
14
+ ```bash
15
+ npm install -g @oxy-hq/cli # then: oxyc
16
+ npx @oxy-hq/cli routes # zero-install; works for everything but `skills install`
17
+ ```
18
+
19
+ From a checkout:
17
20
 
18
21
  ```bash
19
22
  cd sdk/cli && pnpm install && pnpm build
20
- node dist/main.mjs --help # or `pnpm link --global`, then: oxyc
23
+ node dist/main.mjs --help
24
+ pnpm link --global # or, for `oxyc` on PATH
21
25
  ```
22
26
 
23
- Once it is published, the two lines above become:
27
+ Requires `node >= 20`. `gh` (authenticated) is required for the customer
28
+ commands, `jq` for `--jq`, `git` for the repo commands. Each is reported by
29
+ name with its install line the first time a command needs it.
30
+
31
+ ## Quick start
24
32
 
25
33
  ```bash
26
- npx @oxy-hq/cli --help # no install
27
- pnpm add -g @oxy-hq/cli # then: oxyc
34
+ oxyc login --env dev # browser flow; stores a token
35
+ oxyc whoami --env dev # verify it against the deployment
36
+
37
+ oxyc routes threads # find the endpoint
38
+ oxyc schema {workspace}/threads -X POST # find the body it takes
39
+ oxyc api {workspace}/threads --md # call it
28
40
  ```
29
41
 
30
- ## Talking to the API
42
+ ## Global flags
31
43
 
32
- ```bash
33
- oxyc login --env dev # browser flow; shares its token with `oxy login`
34
- oxyc whoami # who the token is, checked against the server
44
+ Accepted by every command except `validate`, `guide`, `exit-codes`, `skills`
45
+ and `cache`.
46
+
47
+ | Flag | Default | Meaning |
48
+ | --- | --- | --- |
49
+ | `--env <name\|url>` | `production` | deployment to target |
50
+ | `--target <url>` | — | explicit base URL; overrides `--env` |
51
+ | `--token-env <VAR>` | `OXY_TOKEN` | env var holding the bearer token |
52
+ | `--api-key-env <VAR>` | `OXY_API_KEY` | env var holding the key for `/external/api` |
53
+ | `--org <slug>` | — | value for the `{org}` placeholder |
54
+ | `--workspace <id>` | — | value for the `{workspace}` placeholder |
55
+ | `--project <id>` | — | value for the `{project}` placeholder |
56
+ | `--customer <name>` | — | act as though run inside this customer's repo |
57
+ | `--quiet` | — | suppress progress messages on stderr |
58
+
59
+ ### Environments
60
+
61
+ `--env` takes a name or any URL. Anything not in this table is used as a URL.
62
+
63
+ | `--env` | URL |
64
+ | --- | --- |
65
+ | `local` | `http://localhost:5173` |
66
+ | `dev`, `development` | `https://aip.dev.oxy.tech` |
67
+ | `staging` | `https://aip.staging.oxy.tech` |
68
+ | `production`, `prod` (default) | `https://app.oxygen-hq.com` |
69
+
70
+ Credentials are stored **per deployment**, and the default is production. A
71
+ 401 against dev usually means you are logged into prod — pass `--env` to
72
+ `login` and to the call.
73
+
74
+ ## `oxyc api`
35
75
 
36
- oxyc routes threads # what exists, and what each endpoint does
37
- oxyc schema {workspace}/threads -X POST # the body it expects
38
- oxyc api {workspace}/threads --jq '.threads[].title'
76
+ ```
77
+ oxyc api <path> [flags]
39
78
  ```
40
79
 
41
- ## Development
80
+ `<path>` is relative to `/api`; a leading `/` or `api/` is accepted.
81
+
82
+ | Flag | Meaning |
83
+ | --- | --- |
84
+ | `-X, --method <verb>` | HTTP method. Default GET, or POST when a body is present. |
85
+ | `-f, --raw-field <k=v>` | string parameter |
86
+ | `-F, --field <k=v>` | typed parameter — `true`, `3`, `["a"]`, `@file`, `@-` |
87
+ | `-H, --header <name:value>` | extra header (repeatable) |
88
+ | `--input <file\|->` | raw request body from a file, or `-` for stdin |
89
+ | `-q, --jq <expr>` | filter the response through `jq` |
90
+ | `--md` | render the result as a markdown table |
91
+ | `--paginate` | follow every page and return one document |
92
+ | `--paginate-key <field>` | the field holding the rows, when the guess is wrong |
93
+ | `--max-pages <n>` | stop after `n` pages (default 100) |
94
+ | `--slurp` | with `--paginate`, emit an array of pages instead of merging |
95
+ | `--cache <duration>` | reuse a recent successful GET (`30s`, `5m`, `2h`) |
96
+ | `-i, --include` | print the status line and response headers |
97
+ | `--silent` | make the request, print nothing |
98
+ | `--verbose` | log the request before making it |
99
+ | `--timeout <duration>` | request timeout (default `2m`) |
100
+
101
+ **Bodies.** `-F 'ids[]=a' -F 'ids[]=b'` accumulates into an array. On GET, HEAD
102
+ and DELETE, fields become query parameters instead of a body.
103
+
104
+ **Placeholders.** `{org}`, `{workspace}`, `{project}`, `{customer}` and `{me}`
105
+ are resolved from the customer repo you are standing in, from an org URL passed
106
+ to `--env`, or from `--org` / `--workspace` / `--project`. An unresolvable
107
+ placeholder is an error naming the flag that fills it — never a literal sent to
108
+ the server.
109
+
110
+ **Surfaces.** The path selects the credential: `/api/**` sends the bearer from
111
+ `oxyc login`; `/external/api/**` sends `X-API-Key` from `$OXY_API_KEY`.
112
+
113
+ **`--paginate` is a heuristic.** Oxy sends `Link: rel="next"` on some endpoints
114
+ and nothing on others; where there is no header, `oxyc` reads
115
+ `pagination.has_next`, then `has_more`, then `page < total_pages`. Nothing
116
+ recognised means one page, and it warns when the server said nothing at all.
42
117
 
43
118
  ```bash
44
- oxyc validate # check the workspace's YAML against the schemas
45
- oxyc proxy --env dev # local app dev against cloud data
46
- oxyc mcp # serve the API as MCP tools, for an agent runtime
47
- oxyc guide # a page to paste into AGENTS.md / CLAUDE.md
48
- ```
49
-
50
- **`oxyc validate`** checks `config.yml`, `.automation.yml`, `.agentic.yml`,
51
- `.app.yml` and `.agent.test.yml` against `json-schemas/*.json` — which are
52
- generated from the Rust config types, so this is a second *reader* of one
53
- definition rather than a second definition. It does not replace `oxy validate`:
54
- that one loads the workspace and can resolve `databases:` and `llm.ref`. Where
55
- they disagree, it wins. No network, no token — the one command that works
56
- entirely offline.
57
-
58
- **`oxyc proxy`** forwards a dev server's Oxy calls to a cloud target with your
59
- login token attached. Side-effecting calls are **held** and tracking events
60
- **dropped** by default (`--allow-writes` / `--allow-events`); a production
61
- target is refused without `--yes`.
62
-
63
- **`oxyc mcp`** serves the API as four MCP tools — `oxy_routes`, `oxy_schema`,
64
- `oxy_request`, `oxy_whoami` — over stdio. Four rather than one per endpoint
65
- because an agent runtime ships every tool's schema on every turn, and ~670 of
66
- them is tens of KB per request; discovery stays a question the agent asks.
119
+ oxyc api {org}/workspaces --md
120
+ oxyc api {workspace}/threads -q '.threads[].title'
121
+ oxyc api {workspace}/sql/query -X POST -f 'sql=select 1' --md
122
+ oxyc api /admin/users --paginate --md
123
+ ```
124
+
125
+ ## Discovery
67
126
 
68
127
  ```bash
69
- claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --env production
128
+ oxyc routes [filter] [--json] [--all] [--refresh] # endpoints this deployment mounts
129
+ oxyc schema <path> [-X <verb>] # request/response shape for one
130
+ oxyc openapi # the whole OpenAPI document
70
131
  ```
71
132
 
72
- `oxyc api` follows `gh api` down to the flag letters:
133
+ `routes` is served by `GET /api/_catalog`, so it describes the deployment you
134
+ are talking to rather than a baked table. It is cached per host; `--refresh`
135
+ re-asks. `--all` includes ide-only and worker-only mounts. `--json` adds each
136
+ route's surface and fleet role.
73
137
 
74
- | | |
75
- | --- | --- |
76
- | `-X, --method` | HTTP method. Defaults to GET, or POST with a body. |
77
- | `-f, --raw-field k=v` | a **string** parameter |
78
- | `-F, --field k=v` | a **typed** parameter — `true` / `3` / `["a"]` / `@file` / `@-` |
79
- | `-F 'ids[]=a' -F 'ids[]=b'` | repeats accumulate into an array |
80
- | `--input @body.json` | raw body from a file, or `-` for stdin |
81
- | `-q, --jq <expr>` | filter through `jq` |
82
- | `--md` | render results as a markdown table — handles arrays of objects, `{columns, rows}`, and the header-row-first arrays `/sql/query` returns |
83
- | `--paginate` / `--slurp` | walk every page; `--slurp` keeps them separate |
84
- | `--cache 5m` | reuse a recent successful GET |
85
- | `-i` / `--silent` / `--verbose` | headers / nothing / log the request |
86
-
87
- On GET, HEAD and DELETE, fields become query parameters instead of a body.
138
+ ## Authentication
88
139
 
89
- **Placeholders.** `{org}`, `{workspace}`, `{project}`, `{customer}` and `{me}`
90
- are filled from context — the customer repo you are standing in, a pasted
91
- `--env` URL, or `--org` / `--workspace` / `--project`. An unresolved
92
- placeholder is an error naming the flag that would fill it, never a literal
93
- sent to the server.
140
+ ```bash
141
+ oxyc login [--env <e>] [--login-env <e...>] [--assume <org> -r <why>]
142
+ oxyc whoami [--json]
143
+ oxyc token # print the bearer, for a raw curl
144
+ oxyc logout
145
+ ```
146
+
147
+ `--login-env` is repeatable and comma-separated (`--login-env dev,staging`);
148
+ the browser opens once per environment, in sequence.
94
149
 
95
- **Surfaces.** `/api/**` takes the bearer from `oxyc login`; `/external/api/**`
96
- takes `X-API-Key` from `$OXY_API_KEY`. The path picks, so you never have to.
150
+ Credentials live in the OS config directory under **`oxy`** — the same file the
151
+ Rust `oxy login` wrote before it was removed, so an existing login still works:
97
152
 
98
- **Exit codes** are the point of the tool for a script or an agent — `oxyc
99
- exit-codes` prints them. `4` is "log in again", `5` is "no such thing", `7` is
100
- "retryable", `2` is "you called it wrong".
153
+ - macOS: `~/Library/Application Support/oxy/credentials.json`
154
+ - Linux: `$XDG_CONFIG_HOME/oxy/credentials.json`
101
155
 
102
- ## Working on a customer account
156
+ `OXY_CREDENTIALS_PATH` overrides the path. Caches are separate, under `oxyc`
157
+ (`~/.cache/oxyc`). In CI set `OXY_TOKEN` instead of logging in.
158
+
159
+ ## Acting as an organization
103
160
 
104
161
  ```bash
105
- oxyc list # who the customers are
106
- oxyc <customer> # a Claude session scoped to them
107
- oxyc <customer> --here # …while you work in one of OUR repos
108
- oxyc doctor <customer> # what the tool knows, changing nothing
109
- oxyc update <customer> # drift from the workspace template
110
- oxyc adopt <customer> # give an IMPORTED repo the managed files
111
- oxyc activity <customer> # merged PRs, theirs and ours
162
+ oxyc assume start --org <slug|uuid|url> -r "<reason>"
163
+ oxyc assume status [--json]
164
+ oxyc assume end [--org <slug>] [--all]
112
165
  ```
113
166
 
114
- ## Acting as an org
167
+ Sessions last **60 minutes and are not renewable** — re-running `start` returns
168
+ the existing session rather than extending it. `--org` is optional when `--env`
169
+ is itself an org URL. The reason is recorded in the audit log.
170
+
171
+ The session belongs to your account, not this terminal, so your browser shares
172
+ it and `end` ends it in both. A staff 403 on a tenant surface is usually no
173
+ active session rather than a role problem; `assume status` answers "none"
174
+ without failing.
175
+
176
+ ## OLTP databases
177
+
178
+ Staff can see and request an org's transactional Postgres — the store behind
179
+ `ctx.oltp` — without a shell on the server.
115
180
 
116
181
  ```bash
117
- oxyc assume start --org acme -r "triage #123" # 60 min, not renewable
118
- oxyc assume status # what is live, minutes left
119
- oxyc assume end # or --all
120
- oxyc login --login-env dev,staging # log into several at once
182
+ oxyc oltp status [--org <slug|uuid|url>] [--json]
183
+ oxyc oltp provision --org <slug|uuid|url> --writer app:<slug> [--writer pipeline:<source> …] [--yes] [--json]
121
184
  ```
122
185
 
123
- A staff 403 on a tenant surface usually means **no active session**, not a
124
- mis-modeled role. The session belongs to your account rather than this
125
- terminal, so your browser is in there too — and `end` gets you out of both.
186
+ `status` lists every org, with a database or without; with `--org` it shows
187
+ that org's store and writers. No credential is ever printed.
188
+
189
+ `provision` prints what it will create and asks first, because it calls a paid
190
+ provider; without a terminal it refuses unless `--yes`. It is idempotent — an
191
+ existing database or writer is reconciled, not duplicated. `app:<slug>` is
192
+ derived the way the platform derives `ctx.oltp`'s schema (`app:store-ops` →
193
+ `app:store_ops`, schema `app_store_ops`); `pipeline:<source>` names
194
+ `raw_<source>`. A 404 can be an org outside your staff grant's scope, and an
195
+ active `assume` session closes `/admin`.
196
+
197
+ ## Customer workspaces
126
198
 
127
199
  A customer **is** a GitHub repo carrying the `oxy-customer` topic. Tagging the
128
- repo is the whole of registration — there is no customer file anywhere, which
129
- is why there is nothing to keep in sync.
200
+ repo is the whole of registration; there is no separate registry file. These
201
+ commands need `gh` authenticated.
202
+
203
+ ```bash
204
+ oxyc list [--json] [--refresh] # the customers
205
+ oxyc path <customer> # where their repo is on this machine
206
+ oxyc new <customer> [--display <name>] # create, tag and scaffold a repo
207
+ oxyc import <org/repo> [--clone] # tag an existing repo
208
+ oxyc remove|rm <customer> [--purge --yes] # untag (never deletes the repo)
209
+ oxyc doctor [<customer>] [--all] # report state, change nothing
210
+ oxyc update <customer> [--apply] [--diff-all] # drift from the workspace template
211
+ oxyc adopt <customer> [--apply] # install managed files an import lacks
212
+ oxyc activity <customer> [--since <date>] [--repo <org/name>] [--write] [--json]
213
+ oxyc launch <customer> [claude-args...] [--here] [--dry-run]
214
+ oxyc repos [--refresh] # where OUR repos are checked out
215
+ ```
130
216
 
131
- `update` and `adopt` **report by default and write only with `--apply`**, and
132
- neither ever commits, branches or pushes. What each may rewrite is decided by
133
- `template/.oxyc-managed` and nothing else: a file nobody classified is the
134
- customer's and is never touched.
217
+ `update` and `adopt` **report by default and write only with `--apply`**.
218
+ Neither commits, branches or pushes. What they may rewrite is decided by
219
+ `template/.oxyc-managed`: an unclassified file belongs to the customer and is
220
+ never touched.
135
221
 
136
- ## Environment
222
+ `launch` starts a Claude Code session scoped to one customer; `--here` runs in
223
+ the current directory while granting access to the customer's repo.
224
+
225
+ ## Custom apps
226
+
227
+ ```bash
228
+ oxyc publish [--env <e>] [--dir <path>] [--promote] [--build-only | --prebuilt] [--json] [--allow-function-lint]
229
+ oxyc init-ci [--app <org>/<app>] [--environment <name>] [--force]
230
+ oxyc proxy [--port <n>] [--allow-writes] [--allow-events] [--yes]
231
+ ```
232
+
233
+ **`publish`**, from an app directory, runs `oxy-app.json`'s build (default
234
+ `pnpm install`, `pnpm build`, output `out/`), bundles each declared Oxy Function
235
+ with `pnpm exec esbuild` into `functions/<name>.js`, resolves the project from
236
+ the target's public `build-config`, and uploads the bundle — to the **draft**
237
+ channel unless `--promote`. `--env` defaults to **production**; name it.
238
+
239
+ - **Identity**: `--org` / `--app`, then `OXY_ORG` / `OXY_APP`, then the manifest's
240
+ `orgSlug` / `slug`, then an `apps/<org>/<app>/` working directory. `--org`
241
+ takes a slug or a UUID. `--project` pins the workspace (and implies its org).
242
+ - **`--dir`** publishes a pre-built directory instead of building; functions are
243
+ still bundled into it. **`--build-only`** stops after building and bundling —
244
+ no credential, no network unless the org must come from `--project`.
245
+ **`--prebuilt`** (with `--dir`) skips esbuild and refuses if a declared
246
+ function's `functions/<name>.js` is missing. The pair splits CI so the job that
247
+ runs package scripts never holds the credential.
248
+ - **Auth**: the `--token-env` variable (`OXY_TOKEN`), then the login cache — or,
249
+ in a GitHub Actions job with `id-token: write` and neither set, **trusted
250
+ publishing**: the job's OIDC token is exchanged for a credential scoped to this
251
+ one app. That needs the org **slug** and a publisher registered for the
252
+ workflow (see `init-ci`).
253
+ - **Provenance**: the checkout's `origin`, `HEAD` and branch (else `GITHUB_SHA` /
254
+ `GITHUB_REF_NAME`) are recorded; a missing half or a dirty app directory is a
255
+ warning, never a failure. The build id defaults to
256
+ `$GITHUB_SHA-$GITHUB_RUN_ID.$GITHUB_RUN_ATTEMPT`, else random — a reused one is
257
+ a `409`.
258
+ - `.env.local`, then `.env`, are read from the directory and its parents without
259
+ overriding the environment. The server's warnings print on stderr; `--json`
260
+ prints its result on stdout.
261
+ - **Function lint**: before the build, each declared function's source (and the
262
+ relative imports it reaches) is checked for what the host refuses at the first
263
+ call — a `ctx.*` call whose capability the manifest lacks, a global the isolate
264
+ does not have, a `ctx.warehouse` / `ctx.tx` write outside `destinations`; before
265
+ the upload, with the target's database list, an `upsert` or `ctx.tx` on an
266
+ engine that refuses it and a customer-warehouse write with no
267
+ `customerWarehouseWrites` reason. The rules are `validate`'s, below. A finding
268
+ fails the publish with exit `1` and names the file, line, call and fix; if the
269
+ target will not list the databases, the engine half prints one warning and is
270
+ skipped. **`--allow-function-lint`** is the way past a false positive: every
271
+ finding becomes a warning that names its rule — please open an issue with it.
272
+
273
+ **`init-ci`** writes `.github/workflows/oxy-publish.yml` at the repo root: a
274
+ `build` job (no id-token) that runs `publish --build-only`, and an
275
+ environment-gated `publish` job whose only work is `publish --prebuilt` with
276
+ OIDC. `--promote` makes that publish go live and adds a `checks run` step after
277
+ it, when the manifest declares a check. It prints the `oxyc api …/publishers`
278
+ call that registers the workflow.
279
+
280
+ ## Checks
281
+
282
+ ```bash
283
+ oxyc checks run <org-slug>/<app-slug> # or an app UUID
284
+ oxyc checks run acme/dashboards --json --timeout 60
285
+ ```
286
+
287
+ Runs every function the app declared `"check": true` (in `oxy-app.json`), one
288
+ POST per check to start its run, then polls each run to a terminal status —
289
+ `done`, `failed`, `cancelled`, or a client-side `timed_out` past `--timeout`
290
+ seconds (default 300, per check). A check **passes** when its run finishes
291
+ `done` and its answer does not parse to an object with `ok: false`.
292
+
293
+ Human mode prints one line per check to stderr as it lands (`✓ canary 4.1s` /
294
+ `✗ canary failed: <reason>`), suppressed by `--quiet`. `--json` prints one
295
+ object to stdout instead:
296
+
297
+ ```json
298
+ { "app": "acme/dashboards", "appId": "…", "checks": [{ "name": "canary", "runId": "…", "status": "done", "passed": true, "durationMs": 4123 }] }
299
+ ```
137
300
 
138
- | | |
301
+ **Auth** follows the same rule as `oxyc api`'s bearer surface, with one
302
+ difference: `checks run` talks to `/api/admin/**`, which is not the
303
+ `/external/api/*` surface, so a configured API key is sent explicitly as
304
+ `X-API-Key` only when **no bearer resolves** — never both. A bearer from
305
+ `oxyc login` (or `--token-env`) always wins when one is present.
306
+
307
+ **In CI, nothing is stored.** With neither a token nor an API key set, a job
308
+ holding `id-token: write` exchanges its GitHub OIDC token for the same
309
+ short-lived, app-scoped publish credential `oxyc publish` uses, and drives
310
+ `/api/customer-apps/**` instead — the same three handlers, mounted where a
311
+ publish token may reach them. The exchange returns the app id, so no lookup is
312
+ needed, and `<app>` must be `<org-slug>/<app-slug>` in this mode (a UUID names
313
+ an app the exchange cannot verify a publisher for). A publish token set in
314
+ `OXY_TOKEN` takes the same surface without minting.
315
+
316
+ What that credential may do is deliberately small, and enforced server-side
317
+ rather than by the client. An app-scoped token is **confined to its app**: it
318
+ reaches `/customer-apps/{its own id}/…` and the upload, and answers `404` for
319
+ everything else on that surface — another app's id, the registry listing, and
320
+ the fleet-wide rollups. That is the same answer an out-of-scope App Operator
321
+ gets, so it cannot learn which apps exist. Within its app it may publish, read,
322
+ and run a function **the manifest marks `"check": true`**; a function without
323
+ the flag is `403`. The
324
+ argument for allowing the run at all is narrow: a token that may publish
325
+ arbitrary code to an app can already cause anything that app can, so running
326
+ that app's own declared checks adds nothing. It would stop being true the moment
327
+ it admitted any function.
328
+
329
+ **A check runs against the app's live build** — the server resolves
330
+ `published_build_id`, else `draft_build_id`, for the pre-flight lookup and for
331
+ the execution alike. So `checks run` after a plain `oxyc publish` verifies the
332
+ *previously promoted* code, not the draft just uploaded. `oxyc init-ci` writes
333
+ the step only with `--promote`, where the build just published is the one the
334
+ check runs, and only when the manifest declares a check.
335
+
336
+ With a publish token, `<app>` must be the **UUID** — resolving a slug means
337
+ listing every app, which such a token may not do. The OIDC path needs no id at
338
+ all (the exchange returns it) but does need `<org-slug>/<app-slug>`, since the
339
+ exchange is registered by slug.
340
+
341
+ **Exit codes:** `0` every check passed; `9` (`CHECK_FAILED`) at least one check
342
+ failed or timed out; `1` the app declares no checks; `5` the app was not
343
+ found; `4` no credential resolved, or the API rejected it (401/403 — an
344
+ expired 90-day API key reads this way, not as a missing one); `2` a bad
345
+ `--timeout`, or a slug where the credential needs a UUID; `7` (`UNAVAILABLE`)
346
+ the deployment answered the OIDC exchange without an `app_id`, meaning it
347
+ predates trusted checks.
348
+
349
+ ## Development commands
350
+
351
+ ```bash
352
+ oxyc validate [-f <file>] [--json]
353
+ oxyc mcp
354
+ oxyc guide
355
+ oxyc skills install | list
356
+ oxyc cache clear
357
+ oxyc exit-codes
358
+ ```
359
+
360
+ **`validate`** checks workspace YAML against `json-schemas/*.json`, which are
361
+ generated from the Rust config types. No network, no token.
362
+
363
+ | File | Schema |
139
364
  | --- | --- |
140
- | `OXY_TOKEN` | bearer, overriding the login cache (the CI path) |
141
- | `OXY_API_KEY` | key for the `/external/api` surface |
142
- | `OXYC_ORG` | the GitHub org customer repos live in (default `oxy-hq`) |
143
- | `OXYC_DOSSIER_ROOT` | where customer clones go (default `~/.oxyc/dossiers`) |
144
- | `OXYC_TEMPLATE_DIR` | use a working copy of the template instead of the shipped one |
145
- | `NO_COLOR` | plain output |
365
+ | `config.yml` / `config.yaml` | `config.json` |
366
+ | `*.automation.yml`, `*.procedure.yml`, `*.workflow.yml` | `workflow.json` |
367
+ | `*.agentic.yml` | `agentic.json` |
368
+ | `*.app.yml` | `app.json` |
369
+ | `*.agent.test.yml` | `agent-test.json` |
370
+
371
+ Structural checks only. `oxy validate` additionally resolves `databases:` and
372
+ `llm.ref` against the loaded workspace, and wins where the two disagree.
373
+
374
+ Every **`oxy-app.json`** it finds is checked for data placement — the shape of
375
+ the data picks the store: facts and history in Airhouse (`ctx.airhouse`),
376
+ records in OLTP (`ctx.oltp`), files in `ctx.storage`, customer warehouses
377
+ read-only. These fail the run:
378
+
379
+ - `customerWarehouseWrites` that is not `{ "<database>": "<reason>" }`, or names
380
+ a database missing from the function's `destinations`;
381
+ - `"airhouse": { "enabled": true }` in an app whose slug cannot derive a writer
382
+ (`-` → `_`, 1–56 of `[a-z0-9_]`, starting with a letter; no `_` in the slug);
383
+ - `airhouseMigrations` without a `dir`, with one that does not exist, or with
384
+ `migrations.dir`'s; and in its `*.sql`, `PRIMARY KEY`, `UNIQUE`,
385
+ `CREATE INDEX`, `REFERENCES` or `FOREIGN KEY` (DuckLake has none), or a
386
+ `CREATE`/`ALTER`/`DROP`/`INSERT`/`UPDATE`/`DELETE`/`COMMENT ON` target not
387
+ written as `app_<writer>.<name>`.
388
+
389
+ Warnings print on stderr and do not fail: each customer-warehouse exception,
390
+ `CREATE SCHEMA` in a migration, and `ctx.secrets.set` holding state (a
391
+ `JSON.stringify` value, or a state-like key that is not a credential's). The SQL
392
+ checks are lexical; the server's parser is the authority.
393
+
394
+ Each declared **Oxy Function** is then linted — its entry and every relative
395
+ import it reaches, comments and strings masked — for the mistakes that work
396
+ locally and fail closed in production. These fail the run, each naming the
397
+ file, the line, the call and the fix:
398
+
399
+ - `capability` — a `ctx.<area>.<op>` call whose manifest capability the function
400
+ does not declare: `ctx.secrets.set` → `secrets.write`, `ctx.email.send` →
401
+ `email.send`, `ctx.org.*` → `org.read`, `ctx.storage.*` → `storage.read` /
402
+ `storage.write` (`copy` needs both), `ctx.oltp.*` → `oltp.enabled`,
403
+ `ctx.airhouse.*` → `airhouse.enabled`. The map is
404
+ `src/publish/capabilities.ts`, held to the host's `FunctionCapabilities` by a
405
+ Rust test.
406
+ - `isolate-global` — a value use of `Buffer`, `TextEncoder`, `TextDecoder`,
407
+ `Blob`, `File`, `FormData`, `crypto.subtle` or `process.*`: the isolate is bare
408
+ `deno_core` and each is a `ReferenceError` at runtime. A name the file declares
409
+ itself (`const Buffer = …`, `import { TextEncoder } from "./polyfill"`) is not
410
+ one; nor is a `typeof` guard or a type annotation.
411
+ - `destinations` — a `ctx.warehouse.insert` / `exec` / `upsert` or `ctx.tx` in a
412
+ function with no `destinations`, or naming a database not in them.
413
+
414
+ The engine half — `upsert` off Postgres / DuckDB, `ctx.tx` off Postgres, a
415
+ customer-warehouse write with no `customerWarehouseWrites` reason, a write to
416
+ the org's OLTP store — needs the database list, which only the server has;
417
+ `validate` says so and `oxyc publish` runs it before the upload.
418
+
419
+ **`proxy`** forwards a local dev server's Oxy calls to a cloud target with your
420
+ login token attached. Defaults: side-effecting calls are **held**, tracking
421
+ events are **dropped**, auth endpoints reach the backend unauthenticated so
422
+ sign-in works, and the cached token never overrides a real browser session. A
423
+ production target (any host under `oxygen-hq.com`) is refused without `--yes`.
424
+ Each Oxy Function call carries a W3C trace — the SDK's, or one minted here — and
425
+ prints `↳ <status> fn <name> request_id=… trace_id=…`.
426
+
427
+ **`mcp`** serves the API over stdio as four tools — `oxy_routes`, `oxy_schema`,
428
+ `oxy_request`, `oxy_whoami` — rather than one per endpoint, so the tool schemas
429
+ cost ~2 KB per turn and reach endpoints added after this package shipped.
430
+
431
+ ```bash
432
+ claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --env production
433
+ ```
434
+
435
+ **`guide`** prints a page to paste into `AGENTS.md` / `CLAUDE.md`.
436
+ **`skills install`** symlinks the six bundled Claude skills into
437
+ `~/.claude/skills`; it refuses to run from an `npx` cache, whose symlinks dangle
438
+ once npm reclaims it.
146
439
 
147
- ## Two audiences, one tool
440
+ ## Exit codes
441
+
442
+ ```
443
+ 0 success
444
+ 1 failure with nothing more specific to say
445
+ 2 usage error — a bad flag, a missing argument, a malformed value
446
+ 4 not authenticated, or the token was rejected (401/403)
447
+ 5 not found (404), or an unknown customer
448
+ 6 the request was malformed (4xx other than 401/403/404)
449
+ 7 unavailable — 5xx, a timeout, or the network failed. Retryable.
450
+ 8 refused — the operation would have destroyed or overwritten something
451
+ 9 a check ran and failed or timed out (`oxyc checks run`)
452
+ ```
148
453
 
149
- Attached to a terminal you get colour, aligned tables and a searchable picker.
150
- Piped — or read by an agent — you get markdown tables and no colour, because
151
- that is what an LLM reads most reliably and most cheaply. Raw `api` response
152
- bodies are verbatim in both, so `| jq` always works.
454
+ `4` almost always means the wrong `--env`. `7` is worth retrying; `6` never is.
455
+
456
+ ## Environment variables
457
+
458
+ | Variable | Default | Meaning |
459
+ | --- | --- | --- |
460
+ | `OXY_TOKEN` | — | bearer token, overriding the login cache (the CI path) |
461
+ | `OXY_API_KEY` | — | key for the `/external/api` surface |
462
+ | `OXY_CREDENTIALS_PATH` | OS config dir | the shared `credentials.json` |
463
+ | `OXYC_ORG` | `oxy-hq` | GitHub org the customer repos live in |
464
+ | `OXYC_CUSTOMER_TOPIC` | `oxy-customer` | topic that registers a customer repo |
465
+ | `OXYC_DOSSIER_ROOT` | `~/.oxyc/dossiers` | where customer clones go |
466
+ | `OXYC_REPO_ROOTS` | scanned | where to look for our own checkouts |
467
+ | `OXYC_TEMPLATE_DIR` | shipped `template/` | use a working copy instead |
468
+ | `OXYC_SCHEMAS_DIR` | shipped `json-schemas/` | use a working copy instead |
469
+ | `OXYC_SKILLS_DIR` | shipped `skills/` | use a working copy instead |
470
+ | `OXYC_SKILLS_TARGET` | `~/.claude/skills` | where `skills install` links |
471
+ | `OXYC_CACHE_DIR` | `<cache>/oxyc` | cache root |
472
+ | `OXYC_CACHE_TTL` | `3600` | seconds the customer listing is cached |
473
+ | `OXYC_LIST_LIMIT` | `1000` | max repos listed from GitHub |
474
+ | `OXYC_SEARCH_LIMIT` | `1000` | max pull requests searched by `activity` |
475
+ | `OXYC_DIFF_LINES` | — | cap on diff lines printed by `update` |
476
+ | `OXYC_DEBUG` | — | `1` keeps the stack on an unexpected throw |
477
+ | `OXYC_QUIET` | — | `1` is `--quiet` |
478
+ | `OXYC_DRY_RUN` | — | `1` makes `launch` print the command instead of running it |
479
+ | `NO_COLOR` / `FORCE_COLOR` | — | force plain / coloured output |
480
+
481
+ ## Output contract
482
+
483
+ **stdout carries the response body and nothing else.** Progress, warnings,
484
+ errors and hints go to stderr, and a failure never exits `0`.
485
+
486
+ Attached to a TTY you get colour, aligned tables and a searchable picker.
487
+ Piped, you get markdown tables and no colour. Raw `api` response bodies are
488
+ byte-identical in both, so `| jq` always works.
153
489
 
154
- Everything that is not the answer goes to **stderr**. stdout carries the
155
- response body and nothing else.
490
+ ## Development
156
491
 
157
- ## Requirements
492
+ ```bash
493
+ pnpm install
494
+ pnpm build # tsdown; `prebuild` runs codegen
495
+ pnpm test # vitest; `pretest` builds
496
+ pnpm typecheck
497
+ pnpm lint # biome
498
+ pnpm build:binary # standalone executables (needs bun)
499
+ ```
158
500
 
159
- `node >= 20`. `gh` (authenticated) for anything touching the customer registry;
160
- `jq` for `--jq`; `git` for the repo commands. Each is reported by name, with
161
- its install line, the first time something needs it.
501
+ The package ships four directories — `dist`, `json-schemas`, `skills`,
502
+ `template` — and the last three are resolved at runtime relative to
503
+ `package.json`. `scripts/ci/verify-cli-package.mjs` checks the packed tarball
504
+ on every PR.
162
505
 
163
- Maintainer's notes: [`internal-docs/oxy-api-cli.md`](../../internal-docs/oxy-api-cli.md).
506
+ Maintainer's notes — release process, catalog generation, OpenAPI curation, CI
507
+ gates: [`internal-docs/oxy-api-cli.md`](../../internal-docs/oxy-api-cli.md).