@sibylline-labs/eris 0.0.0-stage → 0.3.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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +146 -2
  4. package/bin/eris.ts +42 -0
  5. package/docs/ARCHITECTURE.md +188 -0
  6. package/docs/COMPLETION.md +106 -0
  7. package/docs/ERRORS.md +632 -0
  8. package/docs/OUTPUT-CONTRACT.md +219 -0
  9. package/docs/README.md +30 -0
  10. package/docs/UPDATE.md +159 -0
  11. package/docs/YAK.md +395 -0
  12. package/docs/dev/ADDING-A-COMMAND.md +79 -0
  13. package/docs/dev/DEVELOPMENT.md +88 -0
  14. package/docs/dev/LAYOUT.md +74 -0
  15. package/docs/dev/PLUGINS.md +382 -0
  16. package/docs/dev/RELEASING.md +157 -0
  17. package/docs/dev/TESTING.md +84 -0
  18. package/docs/dev/surfaces/secrets.md +95 -0
  19. package/package.json +55 -4
  20. package/packages/yak-contract/package.json +28 -0
  21. package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
  22. package/packages/yak-contract/src/index.ts +1 -0
  23. package/packages/yak-contract/src/v2.ts +375 -0
  24. package/plugins/index.ts +12 -0
  25. package/plugins/yak/handlers.ts +128 -0
  26. package/plugins/yak/index.ts +51 -0
  27. package/plugins/yak/login.ts +300 -0
  28. package/plugins/yak/yak-context.ts +280 -0
  29. package/plugins/yak/yak-identity.ts +399 -0
  30. package/plugins/yak/yak-redact.ts +686 -0
  31. package/plugins/yak/yak-setup.ts +133 -0
  32. package/plugins/yak/yak-submit.ts +1166 -0
  33. package/plugins/yak/yak.ts +485 -0
  34. package/src/build.ts +248 -0
  35. package/src/cli/args.ts +434 -0
  36. package/src/cli/commands/completion/bash.ts +24 -0
  37. package/src/cli/commands/completion/domain.ts +203 -0
  38. package/src/cli/commands/completion/emit.ts +246 -0
  39. package/src/cli/commands/completion/shell.ts +24 -0
  40. package/src/cli/commands/completion/zsh.ts +24 -0
  41. package/src/cli/commands/completion.ts +48 -0
  42. package/src/cli/commands/update/github.ts +328 -0
  43. package/src/cli/commands/update/opt-in.ts +32 -0
  44. package/src/cli/commands/update/refusal.ts +21 -0
  45. package/src/cli/commands/update/release.ts +351 -0
  46. package/src/cli/commands/update/replace.ts +143 -0
  47. package/src/cli/commands/update/semver.ts +48 -0
  48. package/src/cli/commands/update.ts +47 -0
  49. package/src/cli/commands/usage.ts +68 -0
  50. package/src/cli/commands/version.ts +29 -0
  51. package/src/cli/compose.ts +273 -0
  52. package/src/cli/output.ts +81 -0
  53. package/src/cli/plugin.ts +85 -0
  54. package/src/cli/registry.ts +180 -0
  55. package/src/cli/router.ts +31 -0
  56. package/src/cli/ts-verb.ts +126 -0
  57. package/src/main.ts +223 -0
  58. package/src/runtime/env.ts +16 -0
  59. package/src/runtime/eris-assets.d.ts +5 -0
  60. package/src/runtime/home.ts +306 -0
  61. package/src/runtime/logging.ts +43 -0
  62. package/src/runtime/process.ts +388 -0
  63. package/src/runtime/settings.ts +191 -0
  64. package/src/sdk.ts +62 -0
  65. package/testing/build-child.ts +55 -0
  66. package/testing/fake-runner.ts +23 -0
  67. package/testing/index.ts +16 -0
  68. package/ts/lib/eris-fail.ts +180 -0
  69. package/ts/lib/pool.ts +63 -0
package/docs/YAK.md ADDED
@@ -0,0 +1,395 @@
1
+ # `eris yak` — land a durable observation with the Yaks endpoint
2
+
3
+ Three verbs an agent (or a human) can invoke inline when the harness fights back —
4
+ a broken tool, a confusing error, a config gap — or could serve them better, and never for the task's own
5
+ outcome. `eris yak` redacts, resolves identity, derives a dedup key, makes one
6
+ bounded HTTP call to a Yaks endpoint, and gets out
7
+ of the way. Everything after the receipt — clustering, issue-tracker writes,
8
+ incident routing — belongs to the endpoint, not to this CLI.
9
+
10
+ ```
11
+ eris yak issue --summary "…" --body "…"
12
+ eris yak suggest --summary "…" --body-file -
13
+ eris yak fatal --summary "…" --body-file -
14
+ ```
15
+
16
+ ## Which verb
17
+
18
+ | Verb | When |
19
+ |---|---|
20
+ | `issue` | A harness/tooling defect — a command that errors, a flag that does the wrong thing, a doc that lies. A suspected defect goes here too, with the hedge in the body |
21
+ | `suggest` | An improvement to the harness — a missing verb or flag, a better default, a skill or rule that should exist, change or go. Nothing has to be broken |
22
+ | `fatal` | The harness itself is broken — the thing you're standing on, not the thing you're building |
23
+
24
+ None of the three is for the task's *own* outcome (a test failure in the code
25
+ you're writing, a bug in the feature you're implementing). They exist for
26
+ problems with the tools, not the work.
27
+
28
+ ## Flags
29
+
30
+ ```
31
+ eris yak <issue|suggest|fatal>
32
+ --summary <text> required, ≤ 200 chars
33
+ --body <text> | --body-file <path|-> required, ≤ 4000 chars (`-` = stdin)
34
+ --evidence <text> | --evidence-file <path|-> optional, ≤ 20000 chars
35
+ --repro <command> optional, ≤ 2000 chars — see below
36
+ --repo <owner/name> optional, repeatable, ≤ 200 chars each,
37
+ ≤ 20 total — see below
38
+ --no-redact skip the secret-scrub pass (loud warning)
39
+ ```
40
+
41
+ `--body-file -` and `--evidence-file -` are mutually exclusive — there is only
42
+ one stdin. `--body-file -` with no pipe fails immediately (`yak-stdin-is-a-tty`)
43
+ rather than hanging: a command that blocks forever mid-task is exactly the
44
+ yak-shave this feature exists to remove.
45
+
46
+ Over-length fields fail locally with the actual/max lengths named, rather than
47
+ letting the server truncate silently — a server-side truncation would desync
48
+ the stored payload from the dedup key this CLI already derived from the full
49
+ text (see [Idempotency](#idempotency-and-replay)).
50
+
51
+ ## Setup
52
+
53
+ `eris yak` reads its endpoint URL and bearer token from **one file**,
54
+ `~/.eris/yaks.json`, mode `0600`:
55
+
56
+ ```json
57
+ { "url": "https://yaks.example.com", "token": "…" }
58
+ ```
59
+
60
+ The url is `ERIS_YAKS_URL`, then the file's `url`, then the default
61
+ `https://api.sibylline.group`. Set either only to use another endpoint.
62
+
63
+ ### `eris login`: a device code
64
+
65
+ Sign in from any terminal:
66
+
67
+ ```
68
+ eris login
69
+ ```
70
+
71
+ That signs in to `https://api.sibylline.group`. For another endpoint that
72
+ offers device login, pass `--url https://yaks.example.com`.
73
+
74
+ It prints a link and a short code (and opens the link in a browser when stderr
75
+ is a terminal; `--no-browser` skips that). Approve the code there, and the
76
+ token the endpoint mints is written to `~/.eris/yaks.json` with the url beside
77
+ it, so later runs need no `--url`. The file is replaced only after a token
78
+ arrives, via a temp file created `0600` and renamed into place: a denied or
79
+ expired code writes nothing (`login-denied`, `login-expired`, exit 1). Neither
80
+ the device code nor the token is printed; the short code is the one value
81
+ shown, and it is useless without the approval. Without `--url` the url is
82
+ `ERIS_YAKS_URL`, then the file's own `url`, then the default. The CLI follows RFC 8628: it
83
+ polls `/v1/device/token` at the advertised interval and slows down when told to.
84
+
85
+ `eris logout` removes the token from the file and keeps the url. No route lets
86
+ a token revoke itself, so it stays valid until you revoke it from the
87
+ endpoint's dashboard (Settings > API Tokens).
88
+
89
+ ### `yak-setup.ts`: a hand-registered bearer
90
+
91
+ For a self-hosted endpoint, mint the file with
92
+ `bun plugins/yak/yak-setup.ts --url <endpoint>` (or with `ERIS_YAKS_URL` set)
93
+ from an eris checkout; it refuses without one, because the default endpoint
94
+ signs machines in with `eris login`. It writes the file and prints the
95
+ follow-up step: the endpoint's operator has to register the bearer against
96
+ this machine's source slug, which the script does not automate. Until both
97
+ halves are done, every yak from this machine warns and exits 0 rather than
98
+ failing your task — see [Exit codes](#exit-codes).
99
+
100
+ `ERIS_YAKS_TOKEN` / `ERIS_YAKS_URL` override the file's `token` / `url` fields
101
+ independently. Nothing else is consulted: a per-source token *is* the ingest
102
+ identity, so it never comes from a file in a repository.
103
+
104
+ The directory `~/.eris` itself is `0755`; the **file's** mode is the guard. A
105
+ group- or world-readable `yaks.json` is refused outright — never parsed, never
106
+ logged — rather than silently trusting a permissive `~/.eris`.
107
+
108
+ ## Exit codes
109
+
110
+ The table below describes **text mode** (the default). Under `--output json`
111
+ the diagnostics move entirely onto stdout's record — see below.
112
+
113
+ | Situation | Exit |
114
+ |---|---|
115
+ | Missing `--summary`/`--body`, an unreadable `--body-file`, `--body-file -` with no pipe, an over-length field, an unknown verb or flag, a bare positional word (the text belongs on `--summary`/`--body`) | **`1`** — the four-line `ERROR`/`slug:`/`fix:`/`docs:` block, same contract as every other eris failure |
116
+ | Unresolved token/URL/device-id, an unreachable endpoint, a timeout, a non-2xx response | **`0`** — one `WARN` line + slug on stderr, empty stdout in text mode. A non-2xx status gets its own slug when the cause is on the caller's side (credentials, entitlement, size) and a retry changes nothing — see the rungs below |
117
+ | An incomplete `node_modules` on this machine (`yak-deps-not-installed`) | **`0`** — the same four-line `ERROR`/`slug:`/`fix:`/`docs:` block as the exit-`1` row above, but exit `0`: a broken install on the operator's machine isn't something the calling agent could have done differently |
118
+ | No `bun` on PATH | **`1`** — the shim's own `eris-bun-missing` block, printed before any verb is routed; it is not yak-specific |
119
+ | Accepted (fresh or replay) | **`0`** |
120
+
121
+ The line that decides which bucket a failure falls into: *could the agent have
122
+ done anything differently?* A missing `--summary` is the agent's fault and is
123
+ fixable on the spot. An unset token, an unreachable endpoint, or a machine with no
124
+ network right now is not — failing the task over the operator's local
125
+ environment would be worse than the problem this feature exists to report. So
126
+ **environment, config, and transport problems never fail the task** — they warn
127
+ on stderr and let the calling command continue.
128
+
129
+ ### Rungs for a non-2xx response
130
+
131
+ Every non-2xx response is `WARN` + exit `0`. The endpoint's refusals that a
132
+ retry does not fix get their own slug, so the `WARN` line (and `error.slug`
133
+ under `--output json`) names the remedy instead of a generic transport failure:
134
+
135
+ | Status | Slug | Meaning and fix |
136
+ |---|---|---|
137
+ | `401` | [`yak-unauthorized`](ERRORS.md#yak-unauthorized) | The endpoint rejected the bearer token. Check `~/.eris/yaks.json` / `ERIS_YAKS_TOKEN`, and that the token is registered. |
138
+ | `402` | [`yak-not-entitled`](ERRORS.md#yak-not-entitled) | The workspace is not entitled to Yaks (plan or seat). |
139
+ | `413` | [`yak-payload-too-large`](ERRORS.md#yak-payload-too-large) | The submission exceeded the endpoint's whole-body size cap. Trim `--evidence`. |
140
+ | any other non-2xx | [`yak-transport-error`](ERRORS.md#yak-transport-error) | An outage, a missing route, a proxy page: no local remedy is known. |
141
+
142
+ Each rung's `ERRORS.md` entry notes that a problem with eris itself is reported
143
+ on the vendor route, the eris project's own tracker, rather than into the
144
+ workspace.
145
+
146
+ ### `--output json`
147
+
148
+ Under `--output json`, `eris yak <issue|suggest|fatal>` writes **exactly one
149
+ JSON record to stdout and nothing at all to stderr**, on every outcome above —
150
+ misuse, warn, and delivered alike, including the dispatcher-level check
151
+ (`yak-deps-not-installed`, `plugins/yak/handlers.ts`) that never reaches the
152
+ yak core. Exit codes are unchanged from the table above. The one exception is
153
+ `eris-bun-missing`: the shim prints it before bun starts, so no record exists.
154
+
155
+ The record: `{verb, delivered, id?, idempotency_key?, replay?,
156
+ contract_version?, key_mismatch?, redactions: {count, classes, skipped},
157
+ error?}`. **`delivered` is the field to branch on — never `id == null`**. Three fields exist specifically because their text-mode stderr
158
+ equivalent is suppressed under json and would otherwise be silently lost:
159
+
160
+ - **`redactions.skipped`** — `true` when `--no-redact` was set for this
161
+ submission. A skipped scrub and a scrub that ran and found nothing both
162
+ leave `count: 0, classes: []`; without this field the two are
163
+ indistinguishable on the wire. Text mode's `yak-no-redact` WARN carries the
164
+ same fact.
165
+ - **`key_mismatch`** — present (`true`/`false`) only when `delivered: true`.
166
+ Mirrors text mode's `yak-key-mismatch` WARN — the record's own
167
+ `idempotency_key` is the *receipt's* key, never the locally-derived one the
168
+ mismatch is measured against, so this is the only way a machine caller can
169
+ detect canonicalization drift.
170
+ - **`error.slug`/`error.reason`** — the content of the `WARN`/`ERROR` line
171
+ itself, on every non-delivered outcome.
172
+
173
+ What is *not* reproduced in the record: the `fix:`/`docs:` lines from the
174
+ text-mode failure block. Those are reconstructable from `error.slug` — every
175
+ slug is a stable heading in [ERRORS.md](ERRORS.md) — the same way any other
176
+ `--output json` failure's remediation is looked up.
177
+
178
+ See [OUTPUT-CONTRACT.md §3](OUTPUT-CONTRACT.md#3-machine-surfaces).
179
+
180
+ ## Redaction
181
+
182
+ `summary`, `body`, `evidence`, `repro`, every `--repo` hint, and the gathered provenance are scrubbed before
183
+ anything else sees them — before the dedup key is derived, before the payload
184
+ is validated, before the request leaves the machine. Each match is replaced
185
+ with `«redacted:<class>»`. In text mode the count and class breakdown print
186
+ to **stderr**, never stdout; under `--output json` the same information is
187
+ `redactions.{count,classes}` on the record instead — stderr carries nothing
188
+ either way (see [Exit codes](#--output-json) above).
189
+
190
+ Classes covered: PEM private-key blocks, JWTs, `sib_` service tokens,
191
+ `sibdc_` device codes (what `eris login` polls with),
192
+ AWS access-key ids and `aws_secret_access_key` assignments,
193
+ GitHub/Slack/Google/`sk-`-style API keys, `Authorization: Bearer|token …`
194
+ headers, GCP service-account `"private_key"` values, connection strings with an
195
+ inline password, and **key-name
196
+ assignments** — `NAME=value` or `NAME: value` where `NAME` matches
197
+ `(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|CREDENTIAL|ACCESS_KEY|CLIENT_SECRET|MNEMONIC|SEED|SESSION|COOKIE|AUTH)`.
198
+
199
+ **Redaction is defence in depth, not a boundary.** It is a pattern scrub, and a
200
+ pattern scrub can always miss a shape it wasn't written for. Do not paste
201
+ material into `eris yak` on the assumption that anything sensitive will be
202
+ caught — the honest framing is "this makes an accidental leak less likely," not
203
+ "this makes a report safe by construction." `--no-redact` exists for the
204
+ (rare, and loudly warned) case where the scrub itself is corrupting a report
205
+ that needs to be read verbatim; it does not exist to work around a
206
+ false-positive scrub eating a legitimate report — if that happens, file it as
207
+ its own `eris yak issue` against the redaction pass.
208
+
209
+ ## The wire contract
210
+
211
+ `eris yak` speaks the Yaks submission contract v2, the workspace package
212
+ `@sibylline-labs/yak-contract` ([`packages/yak-contract`](../packages/yak-contract)),
213
+ imported by name rather than vendored as a copy. One call per report:
214
+
215
+ - `POST <url>/v1/yaks` with `Authorization: Bearer <token>`. The ingress
216
+ answers `202` with a receipt `{id, idempotency_key, replay, contract_version}`;
217
+ any `2xx` whose body is a receipt counts as delivered, whatever the
218
+ `contract_version` string says.
219
+ - `route` is always `"workspace"`: the report files into the caller's own
220
+ tenant. It is not an input to the idempotency key.
221
+ - There is no `reporter`. The ingress derives the tracker user from the bearer
222
+ token, so the CLI sends no identity of its own.
223
+ - `schema_version` is the package's `YAKS_CONTRACT_VERSION`. The body is
224
+ validated against the package's `YakSubmission` before the request, so a field
225
+ the contract would refuse never leaves the machine.
226
+
227
+ ## Idempotency and replay
228
+
229
+ Every submission derives a dedup key from `{device_id, execution_id, verb,
230
+ summary, body, evidence}` **after** redaction — the endpoint re-derives the same
231
+ key from the payload it actually received, so deriving from anything else
232
+ would guarantee a mismatch on every report. Two calls with the same inputs in
233
+ the same task dedupe to one observation: the second gets back the *same*
234
+ receipt with `replay: true`, rendered as `already recorded: <id>` in text mode
235
+ (a fresh delivery renders `recorded: <id>`) — an agent can tell "this landed"
236
+ from "this already landed" without re-reporting.
237
+
238
+ The contract exposes no server-side mismatch signal on the receipt itself
239
+ (only a server console warning, invisible to the client). The one drift check
240
+ `eris yak` can make is comparing its own locally-derived key against the
241
+ receipt's `idempotency_key` — a mismatch prints a `yak-key-mismatch` warning
242
+ alongside a still-successful delivery. It is a symptom of this CLI's contract
243
+ package (`packages/yak-contract`) drifting from the endpoint's derivation, not
244
+ something a caller needs to act on.
245
+
246
+ ## Repro
247
+
248
+ `--repro <command>` attaches a command that reproduces the problem being
249
+ reported — a failing test invocation, a `curl`/`eris` one-liner, a short
250
+ repro script's entry point. It rides on the wire inside `provenance` (as
251
+ `provenance.repro.command`) rather than as a new top-level field on
252
+ `YakSubmission`: that contract is the workspace package
253
+ `@sibylline-labs/yak-contract` ([The wire contract](#the-wire-contract)), and
254
+ not something this CLI can extend on its own — `YakProvenance`'s `.catchall()` exists
255
+ precisely so a CLI-side addition like this one doesn't have to wait on a
256
+ contract bump. It is redacted the same as `evidence` (a repro command can
257
+ itself embed a credential — `curl -H "Authorization: Bearer …"` is a
258
+ realistic shape) and bounded to 2000 chars locally, the same courtesy bound
259
+ `summary`/`body`/`evidence` get (see [`yak-repro-too-long`](ERRORS.md#yak-repro-too-long)).
260
+ It does not participate in the idempotency-key derivation — attaching or
261
+ editing a repro never changes an observation's dedup identity.
262
+
263
+ **That stability has a sharp edge: a repro-only resubmission is a silent
264
+ no-op.** The idempotency key is derived from `{device_id, execution_id, verb,
265
+ summary, body, evidence}` (see [Idempotency and replay](#idempotency-and-replay))
266
+ — `repro` deliberately isn't one of those fields, which is the right call
267
+ (you don't want editing a repro to fork a fresh cluster off the same report).
268
+ But it means resubmitting the *same* `summary`/`body`/`evidence` with a
269
+ *different* (or newly-added) `--repro` derives the *same* key, so the endpoint's
270
+ dedup treats it as a replay of the observation it already has: `delivered:
271
+ true` comes back, but no new `YakObservation` is created and the new repro
272
+ text is never stored or seen server-side. There is currently no supported way
273
+ to attach or change a repro on an existing cluster after its first
274
+ observation — only the repro that rode on whichever observation actually
275
+ created (or first populated) the cluster survives.
276
+
277
+ **Sandboxing — what this CLI guarantees, and what it doesn't.** `eris yak`
278
+ never executes the repro command, on this machine or anywhere else; it is
279
+ opaque text from the moment it's typed to the moment it's POSTed. Nothing in
280
+ this codepath (`plugins/yak/handlers.ts`, `plugins/yak/yak.ts`, `plugins/yak/yak-submit.ts`,
281
+ `plugins/yak/yak-context.ts`) shells out, and the submission carries no
282
+ credential, project id, region, or `eris.yaml` env context — `yak-context.ts`
283
+ explicitly excludes a config merge and any cloud-CLI/keychain read from its
284
+ provenance gather (see that file's own header). So the one thing THIS repo
285
+ could leak into a repro — live production access riding along with the
286
+ command — structurally cannot happen here: there is no production
287
+ credential in `eris yak`'s own process to leak.
288
+
289
+ That is the client-side half of the requirement. **Actually rerunning the
290
+ repro is the Yaks endpoint's job, not this CLI's** — "everything after the receipt —
291
+ clustering, issue-tracker writes, incident routing — belongs to the endpoint"
292
+ (see the top of this doc), and the same split applies to a rerun: only the
293
+ endpoint (or whatever runner it dispatches to) has cluster state and knows when
294
+ a fix lands. That side owns its own sandbox — an execution environment with
295
+ no path to a production credential, project, or deploy target. This CLI
296
+ captures and transmits `provenance.repro.command`, and that is all it does
297
+ with it.
298
+
299
+ ## Target-repo hints
300
+
301
+ `--repo <owner/name>` names a repo this report concerns — repeatable, for a
302
+ report that spans more than one. It exists so triage doesn't have to run a
303
+ code search across the whole org before it can even clone a worktree: a Yaks
304
+ report with no target-repo hint anywhere forces a manual `search_code` sweep
305
+ just to find out where the problem lives.
306
+
307
+ ```
308
+ eris yak issue --summary "…" --body "…" --repo acme/widgets
309
+ eris yak issue --summary "…" --body "…" \
310
+ --repo acme/widgets --repo acme/widgets-api
311
+ ```
312
+
313
+ **Auto-populated, always overridable.** When no `--repo` is given at all,
314
+ `eris yak` derives one hint from the invoking process's own git remote — the
315
+ same `origin` URL `provenance.git.origin_url` already carries for every
316
+ submission (see [Discoverability](#discoverability) below for what that
317
+ provenance gather already promises: best-effort, never a gate). An agent
318
+ filing a yak from inside a repo checkout gets a hint for free; one filed from
319
+ outside any checkout, or from a repo with no `origin` remote, simply carries
320
+ none. Passing `--repo` at all — even a single one — always wins over the
321
+ derived value; the two are never merged.
322
+
323
+ The derivation understands nested namespaces (a GitLab-style
324
+ `group/subgroup/project` derives in full, never truncated to just
325
+ `subgroup/project`) and refuses outright on a local checkout — a `file://`
326
+ origin or a bare filesystem path has no "owner" at all, and guessing one from
327
+ trailing path components would be a plausible-looking but wrong hint, worse
328
+ than none. An auto-derived hint also clears the same length bound an
329
+ explicit one does; one that wouldn't is dropped rather than sent unbounded.
330
+
331
+ **Not validated against a live repo list.** Like `repro`, a target-repo hint
332
+ is opaque text this CLI carries through, never confirms. A typo'd or
333
+ nonexistent repo name is exactly as harmless (and exactly as un-caught) as a
334
+ typo'd `--repro` command — triage reads it, decides whether it's right, and
335
+ moves on.
336
+
337
+ **On the wire.** Like `repro`, this rides inside `provenance` rather than as
338
+ a new top-level field on `YakSubmission` — the same non-breaking,
339
+ forward-compatible reason (`YakSubmission` is the contract package's, and this
340
+ CLI doesn't extend it). Unlike `repro`, it lands in the
341
+ `provenance.repo` category the CLI already had (`provenance.repo.service`,
342
+ the nearest `eris.yaml`'s declared service — see `plugins/yak/yak-context.ts`), as
343
+ `provenance.repo.target_repos`, always an array even for a single hint. The
344
+ contract types that one key and bounds it (at most 20 hints of at most 200
345
+ chars each), the same limits this CLI checks locally first. It
346
+ does not participate in the idempotency-key derivation, for the same reason
347
+ `repro` doesn't: attaching or changing a target-repo hint should never fork a
348
+ fresh cluster off a report that's otherwise identical, and the same
349
+ repro-only-resubmission caveat in [Idempotency and
350
+ replay](#idempotency-and-replay) applies here too — resubmitting the same
351
+ `summary`/`body`/`evidence` with a different `--repo` dedupes as a replay,
352
+ and the new hint is never stored or seen server-side.
353
+
354
+ **Surfacing it on the rendered issue is the endpoint's work, not this CLI's.**
355
+ Exactly the same split as everything after the receipt (see the top of this
356
+ doc): this CLI captures and transmits `provenance.repo.target_repos`;
357
+ rendering it into the issue belongs to the endpoint's issue-tracker write path.
358
+
359
+ ## What a receipt does not promise
360
+
361
+ **A receipt is not a promise that an issue was created.** The endpoint accepts,
362
+ stores, and deduplicates every well-formed submission — but it may also
363
+ enforce an issue-creation ceiling per rolling window, and an observation
364
+ deferred by that ceiling is still accepted and stored: it just doesn't (yet)
365
+ become an issue. Whether `fatal` also pages anyone depends on how the
366
+ endpoint is configured; the submission succeeds either way.
367
+
368
+ In short: a `0` exit and a `recorded: <id>` line mean *the observation is
369
+ durably stored and deduplicated*. They do not mean *a human has been paged* or
370
+ *a ticket now exists*. If you need to confirm the latter, that's a question
371
+ for the Yaks endpoint's own surfaces, not for this CLI's receipt.
372
+
373
+ ## Discoverability
374
+
375
+ `eris yak --help` and this doc only help someone who already went looking for
376
+ the verb. Teaching an agent that `eris yak` exists is the agent harness's
377
+ job (a skill, a rule, or the project's agent instructions), not this CLI's.
378
+
379
+ ## Not built here
380
+
381
+ `eris yak list` / `get`, an offline queue for a machine with no network,
382
+ automatic/hook-triggered reporting, and any agentic triage of what gets
383
+ reported — all deliberately out of scope for v1. Reading cluster state,
384
+ detecting a fix-mark, dispatching a sandboxed repro rerun, and recording
385
+ pass/fail are server-side work — see [Repro](#repro) above. Rendering the
386
+ `--repo` hint(s) onto the issue is likewise server-side — see [Target-repo
387
+ hints](#target-repo-hints) above.
388
+
389
+ ## See also
390
+
391
+ - [OUTPUT-CONTRACT.md](OUTPUT-CONTRACT.md) — the stdout/stderr split, exit
392
+ codes, and the `--output json` machine surface this command participates in
393
+ - [ERRORS.md](ERRORS.md) — every `yak-*` slug's `ERROR`/`fix`/`docs` heading
394
+ - `eris login --help` — sign this machine in with a device code
395
+ - `plugins/yak/yak-setup.ts --help` — mint this machine's bearer
@@ -0,0 +1,79 @@
1
+ # Adding a command to Eris
2
+
3
+ A new subcommand of an existing group, or a new group. Every new verb is
4
+ TypeScript. Read [DEVELOPMENT.md](DEVELOPMENT.md) first. A group that belongs to
5
+ a product rather than to core is a plugin instead: [PLUGINS.md](PLUGINS.md).
6
+
7
+ ## A subcommand of an existing core group
8
+
9
+ `completion` is the reference: its dispatcher is `src/cli/commands/completion.ts`
10
+ and each shell is one file under `src/cli/commands/completion/`. Say you are
11
+ adding `eris completion fish`:
12
+
13
+ 1. **Handler.** Create `src/cli/commands/completion/fish.ts`, and route to it
14
+ from the dispatcher's `groupArgs("completion", args)` switch.
15
+
16
+ 2. **Register it.** A `"completion fish": { summary, flags, pos, usage }` row in
17
+ `src/cli/registry.ts`. The handler reads its argv with
18
+ `readVerbArgs("completion fish", args, ctx, { help: USAGE })`
19
+ (`src/cli/args.ts`): the result's `flags` and `pos` are typed from the row, so
20
+ a flag the row does not declare can be neither passed nor read. The router
21
+ table, the `--output json` allow-list and the completion tree derive from the
22
+ row with no further edit (`tests/cli-router.test.ts` pins the derived table).
23
+ A positional with a completable domain says so on the same row
24
+ (`complete`, [COMPLETION.md](../COMPLETION.md#how-it-works)).
25
+
26
+ 3. **Output.** Payload on stdout, every diagnostic on stderr
27
+ ([OUTPUT-CONTRACT.md](../OUTPUT-CONTRACT.md)). A machine rendering means
28
+ `json: true` on the registry row and a line in the refusal's fix text
29
+ (`UNSUPPORTED_FIX_SURFACES`, `src/cli/output.ts`); never a per-command
30
+ `--json`. A machine surface is pinned in `tests/output-contract.test.ts` and
31
+ listed in the contract's surface table.
32
+
33
+ 4. **Safety.**
34
+ - A failure is a slugged block (`renderFailureBlock`, `ts/lib/eris-fail.ts`),
35
+ and every slug gets a `docs/ERRORS.md` heading (`tests/errors-doc.test.ts`).
36
+ - A subprocess goes through the runner in `src/runtime/process.ts` with a
37
+ deadline. Nothing is backgrounded; fan-out is bounded.
38
+ - A command a CI job runs completes without an answer.
39
+
40
+ 5. **Tests.** A red-first `bun test` file under `tests/`: the handler with
41
+ injected deps for logic, and a black-box run through `tests/helpers/blackbox.ts`
42
+ for the CLI surface ([TESTING.md](TESTING.md)).
43
+
44
+ 6. **Verify and document.**
45
+
46
+ ```bash
47
+ bunx tsc --noEmit && bun test ./tests
48
+ ./eris completion fish # run it and read its real output
49
+ ```
50
+
51
+ Update the group's doc under `docs/`.
52
+
53
+ ## A new core group
54
+
55
+ 1. **Dispatcher** `src/cli/commands/<group>.ts`: the group usage, the sub switch
56
+ over `groupArgs("<group>", args)` (`bare` and `help` print usage on stdout and
57
+ exit 0), and an `Unknown <group> command: <x>` failure. One file per sub at
58
+ `src/cli/commands/<group>/<sub>.ts`, each reading its argv with `readVerbArgs`.
59
+
60
+ 2. **Register it.**
61
+ - A `<group>: { impl: "ts", summary }` row and one row per sub in
62
+ `src/cli/registry.ts`.
63
+ - A lazily imported entry in `HANDLERS` (`src/main.ts`);
64
+ `tests/cli-registry.test.ts` fails for a `ts` group without one.
65
+ - A line in `COMMANDS` (`src/cli/commands/usage.ts`), the top-level help. Its
66
+ `--output` paragraph is derived from the json rows.
67
+
68
+ 3. **Tests and docs.** Steps 3–6 above apply per sub. Add `docs/<GROUP>.md` and an
69
+ entry in [docs/README.md](../README.md).
70
+
71
+ ## Checklist
72
+
73
+ - [ ] Registry rows (group and every sub, flags exact) and a `HANDLERS` entry
74
+ - [ ] Lazy imports: `--help` works anywhere, with nothing configured
75
+ - [ ] stdout carries only the payload; every failure is a slugged block with an `ERRORS.md` heading and nothing on stdout
76
+ - [ ] A non-prompting path if CI-facing
77
+ - [ ] Nothing backgrounded; any fan-out bounded
78
+ - [ ] Red-first `bun test` coverage at the CLI seam; no network
79
+ - [ ] `bunx tsc --noEmit` and `bun test ./tests` green
@@ -0,0 +1,88 @@
1
+ # Developing Eris
2
+
3
+ How the codebase is put together: the entrypoint, the router, the layers under
4
+ it, and the conventions every change follows.
5
+
6
+ Companion guides: [TESTING.md](TESTING.md) (the `bun test` suite),
7
+ [ADDING-A-COMMAND.md](ADDING-A-COMMAND.md) (a new subcommand or group, end to end)
8
+ [PLUGINS.md](PLUGINS.md) (a verb group as a build-time plugin) and
9
+ [RELEASING.md](RELEASING.md) (cutting a release: the tag, the workflow, npm).
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ git clone https://github.com/sibylline-labs/eris && cd eris
15
+ bun install --frozen-lockfile
16
+ ./eris --help
17
+ ```
18
+
19
+ `./eris` runs the working tree as it stands; put the checkout on your `PATH` to call it as
20
+ `eris`. There is no build step for the checkout. The gates:
21
+
22
+ ```bash
23
+ bunx tsc --noEmit
24
+ bun test ./tests
25
+ ast-grep scan # rules/ over eris's own source
26
+ ast-grep test --skip-snapshot-tests # the rules' own cases, rule-tests/
27
+ shellcheck -s sh -S warning eris
28
+ bun scripts/build.ts && ERIS_BIN=dist/eris-<os>-<arch> bun test ./tests
29
+ ```
30
+
31
+ The last line is the binary oracle: it compiles the binary and runs the same
32
+ suite against it ([TESTING.md](TESTING.md#the-binary-oracle)).
33
+
34
+ ## The entrypoint and the router
35
+
36
+ - **`eris`** is a POSIX `sh` shim. It finds bun, removes
37
+ `BUN_CONFIG_VERBOSE_FETCH`, and execs `bun --no-env-file --config=/dev/null
38
+ src/main.ts`. Its header lists its invariants.
39
+ - **`bin/eris.ts`** is the same entry for an installed package (`bin` in `package.json`): a
40
+ `#!/usr/bin/env -S bun --no-env-file --config=/dev/null` shebang that refuses to run under
41
+ `BUN_CONFIG_VERBOSE_FETCH` and then calls `runProgram`. Run it as `bun bin/eris.ts` or directly
42
+ (`./bin/eris.ts`) to see what an `npx`/`bunx` user sees.
43
+ - **`src/cli/registry.ts`** names core's verbs: one `REGISTRY` row per group, sub
44
+ and nested sub, carrying `impl` (`ts`), long flags with value arity, `json`
45
+ (has a machine rendering) and a summary.
46
+ **`src/cli/compose.ts`** merges them with the build's plugins
47
+ (`plugins/index.ts`). The router table, the `--output json` allow-list, the
48
+ shell completion tree and the top-level usage are derived from the composed rows.
49
+ - **`src/main.ts`** routes each command line. A `ts` verb runs in-process behind
50
+ the global `--output` pre-pass; its handler is a lazily imported entry in
51
+ `HANDLERS` (core's, or a plugin's). Anything else is refused as an unknown
52
+ command.
53
+
54
+ ## Where things live
55
+
56
+ | Path | Holds |
57
+ |---|---|
58
+ | `bin/` | `eris.ts`, the package's `eris` command ([ARCHITECTURE.md](../ARCHITECTURE.md#the-packages-bin)) |
59
+ | `src/cli/` | The registry, composition, the plugin SDK, the router, argv parsing, the output allow-list, the TS route |
60
+ | `src/cli/commands/` | Core's verbs: `completion` and the top-level usage |
61
+ | `src/runtime/` | Logging, the subprocess runner, settings, env reads, the install root |
62
+ | `ts/lib/eris-fail.ts` | The failure block and the WARN lines |
63
+ | `plugins/` | The plugins this build composes (`plugins/index.ts`): `plugins/yak/` is `eris yak` |
64
+ | `packages/` | Bun workspace packages (root `package.json` `workspaces`): `packages/yak-contract/` is the Yaks wire contract v2, which the root `exports` map also names as `eris/yak-contract`. A package imports only itself and what its own manifest declares, and `tests/yak-contract-v2.test.ts` holds that |
65
+
66
+ ## Conventions
67
+
68
+ **The output contract binds every command** ([OUTPUT-CONTRACT.md](../OUTPUT-CONTRACT.md)).
69
+ stdout carries the payload and nothing else; stderr carries every diagnostic;
70
+ `--output json` is global and a command with no JSON rendering refuses it
71
+ (`output-json-unsupported`). Never add a per-command output-format flag.
72
+
73
+ **Failures are slugged.** A failure writes the block from `renderFailureBlock`
74
+ (`ts/lib/eris-fail.ts`) to stderr and nothing to stdout. Every slug has a heading
75
+ in [ERRORS.md](../ERRORS.md); `tests/errors-doc.test.ts` harvests slugs from the
76
+ source and fails on one without.
77
+
78
+ **Argv is read from the row.** A handler reads its flags and operands through the
79
+ registry-driven parser (`readVerbArgs` in core, `readArgs` from the SDK in a
80
+ plugin), never from argv by hand; the `adhoc-argv-parse` rule holds it.
81
+
82
+ **Nothing is backgrounded to render progress, and fan-out is bounded.** A leaked
83
+ descendant holding a capture pipe makes the reader wait forever. Subprocesses go
84
+ through the runner in `src/runtime/process.ts` with a deadline; concurrency is
85
+ capped by `ERIS_BATCH_CONCURRENCY` (`ts/lib/pool.ts`).
86
+
87
+ **Every bun eris launches carries `--no-env-file --config=/dev/null`**, so a
88
+ directory's `.env` and `bunfig.toml` never load.
@@ -0,0 +1,74 @@
1
+ # Source Layout
2
+
3
+ The annotated tree of the eris repo. Read the part for the area you are editing.
4
+
5
+ ## Project Structure
6
+
7
+ ```
8
+ eris # POSIX sh shim: unsets BUN_CONFIG_VERBOSE_FETCH, execs bun src/main.ts
9
+ bin/eris.ts # the package's `eris` command (package.json `bin`, for an npm or bun install): an
10
+ # `env -S bun --no-env-file --config=/dev/null` shebang, refuses BUN_CONFIG_VERBOSE_FETCH
11
+ # (eris-verbose-fetch-set), then runs src/main.ts's runProgram; tests/bin.test.ts
12
+ package.json # @sibylline-labs/eris: `bin`, `exports`, and `files`, the allow-list of what the npm
13
+ # tarball holds (tests/package.test.ts reads `npm pack --dry-run`)
14
+ src/ # The TS program the shim and the bin run
15
+ ├── main.ts # the entry: composes core with the build's plugins (CLI), HANDLERS, main, runProgram
16
+ ├── sdk.ts # `eris/sdk`, the one module through which another repository reaches core (package.json exports):
17
+ │ # the plugin SDK, composition, runProgram, the runtime, build()
18
+ ├── build.ts # build(): compiles a distribution's entry into one binary (eris:assets, stubs,
19
+ │ # the build id and release repository); scripts/build.ts calls it for this repo
20
+ ├── cli/registry.ts # core's verbs (subs, flags, impl, json); the derivations
21
+ │ # (router table, json allow-list) every plane reads
22
+ ├── cli/compose.ts # core rows + plugin rows → table, allow-list, handlers, json fix text;
23
+ │ # collision and completion-slot refusals; the menu option; entitlement locks
24
+ ├── cli/plugin.ts # the plugin SDK: the one core module a plugin imports
25
+ ├── cli/router.ts # the per-verb route, from the derived table
26
+ ├── cli/args.ts # the registry-driven argv parser and the global --output pre-pass
27
+ ├── cli/output.ts # the --output json allow-list and its refusal block
28
+ ├── cli/ts-verb.ts # runTsVerb: pre-pass → allow-list → lock → handler; eris-deps-not-installed
29
+ ├── cli/commands/ # completion.ts + completion/ (emit.ts generates the bash/zsh script from the
30
+ │ # composed registry; it imports the registry only, because every shell start
31
+ │ # runs it; domain.ts checks a row's completion domains), usage.ts (the
32
+ │ # top-level usage: bare eris, -h, --help), version.ts (--version),
33
+ │ # update.ts (eris update: prints the npm commands), update/ (RELEASE_UPDATE,
34
+ │ # the in-place verb a distribution opts into: opt-in.ts the row and thunk,
35
+ │ # release.ts the verb, github.ts the releases API and the download,
36
+ │ # replace.ts the staged rename and its restore, semver.ts)
37
+ └── runtime/ # logging, process (the one spawn adapter), settings (~/.eris/settings.json),
38
+ # env (readEnv), home (the install root: the checkout, a distribution's own root
39
+ # when its entry names one, or a compiled binary's
40
+ # per-build asset home)
41
+ ts/lib/eris-fail.ts # the ERROR/slug/fix/docs failure block, and the WARN lines
42
+ ts/lib/pool.ts # the bounded-concurrency runner process.ts's runMany uses
43
+ plugins/index.ts # the plugins this build compiles in, composed by src/main.ts (docs/dev/PLUGINS.md)
44
+ plugins/yak/ # `eris yak` as a plugin: index.ts (the manifest: rows + handler thunks), handlers.ts
45
+ # (the dispatcher), yak.ts (the core), yak-{identity,context,redact,submit}.ts
46
+ # (yak-redact.ts imports nothing; package.json exports it as `eris/yak-redact`),
47
+ # login.ts (`eris login`/`logout`, the device-code client), yak-setup.ts (mints the bearer; docs/YAK.md); the wire contract is packages/yak-contract, imported by name
48
+ packages/yak-contract/ # the Yaks wire contract v2, a bun workspace package that builds standalone;
49
+ # `eris/yak-contract` (package.json exports) is its src/index.ts.
50
+ # src/v2.ts (Zod + inferred types), scripts/emit-json-schema.ts →
51
+ # schema/yak-v2.schema.json (committed; `bun run emit-schema` in the package re-emits)
52
+ skills/eris-yaks/ # SKILL.md: the agent skill that sets up Yaks (`npx skills add sibylline-labs/eris
53
+ # --skill eris-yaks`); the skills CLI discovers skills/<name>/SKILL.md
54
+ examples/plugins/widgets/ # the worked plugin example tests/plugins.test.ts composes
55
+ examples/distribution/ # the worked distribution: an entry importing eris/sdk alone, a demo plugin with a
56
+ # completion domain, a menu, and build.ts; tests/distribution.test.ts builds and runs it
57
+ testing/ # `eris/sdk/testing` (package.json exports): the test helpers a distribution's suites
58
+ # reuse. fake-runner.ts (createFakeRunner), build-child.ts (buildInChild: build() in a
59
+ # child bun, from a module exporting BUILD). Outside src/, so no binary carries it
60
+ scripts/build.ts # `bun scripts/build.ts` → dist/eris-<os>-<arch> (gitignored): this repo's binary,
61
+ # through src/build.ts; exports its options as BUILD
62
+ .github/workflows/ # ci.yml (the PR gates, the npm tarball's install-and-run included) · release.yml (tag → build, oracle, publish, npm)
63
+ sgconfig.yml # the code-quality rules over eris's OWN source (ruleDirs: rules; cases in rule-tests/):
64
+ rules/ # adhoc-argv-parse (one argv parser), env-read-own-property(-plugin) (env names
65
+ rule-tests/ # are read through readEnv), plugin-manifest-lazy-handlers + plugin-imports-sdk-only
66
+ # (the plugin contract), distribution-imports-sdk-only (eris/sdk alone),
67
+ # runtime-imports-no-testing (the program never imports testing/),
68
+ # regex-no-lookbehind (the pinned bun interprets a lookbehind pattern)
69
+ tests/ # `bun test ./tests` — the whole suite. helpers/blackbox.ts drives the real
70
+ # `./eris` in a physical per-test sandbox (PATH fakes, hideReal); helpers/plugins/
71
+ # holds a tripwire plugin; helpers/release-fixture.ts is GitHub's releases API
72
+ # on loopback, for eris update
73
+ docs/ # ARCHITECTURE.md, OUTPUT-CONTRACT.md, ERRORS.md, the verb docs, dev/
74
+ ```