@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
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Yaks wire contract v2: the shapes an `eris` client posts
3
+ * to the Yaks ingest API, and the shapes the service stores and reports back.
4
+ *
5
+ * Derived from the v1 contract (contract version 1.0.0, which `eris yak`
6
+ * spoke before it imported this package). Whatever is not listed here is v1
7
+ * unchanged. The deltas:
8
+ * - `reporter` is gone. The server derives the tracker user from the caller's
9
+ * token. A client's git identity may still ride in `provenance.git` as a hint
10
+ * and is never an identity claim. Unknown keys are stripped, as in v1, so a
11
+ * client that still sends `reporter` is accepted and the field is dropped.
12
+ * - `route` is new and required: `workspace` files into the caller's own
13
+ * tenant, `vendor` into the vendor's.
14
+ * - `provenance.repo.target_repos` keeps the wire location v1's clients use and
15
+ * gains a type and bounds (the server's routing reads it).
16
+ * - `provenance.execution.rung` names which rung of the client's execution-id
17
+ * ladder produced `execution_id`. Its vocabulary holds v1's rungs and the
18
+ * generic harness rungs `ai-agent`, `codex` and `cursor`.
19
+ *
20
+ * Design invariants it encodes:
21
+ * - The tenant is never in the payload: it comes from the bearer token. The
22
+ * same holds for `source` on a stored observation.
23
+ * - Exactly three verbs: `issue`, `suggest`, `fatal`. A fourth is a change to
24
+ * this file.
25
+ * - A submission is immutable once accepted: `YakSubmission` is written once as
26
+ * the `data` blob on a `YakObservation` and never rewritten. Everything that
27
+ * changes as an observation is processed lives in sibling fields.
28
+ * - The idempotency key is SERVER-authoritative. The client may send one,
29
+ * advisory only. The server re-derives it from the payload it received via
30
+ * `deriveIdempotencyKey`, and that value is what the receipt reports and what
31
+ * dedup keys off. The derivation is v1's unchanged, and the server scopes the
32
+ * key per tenant. `route` is not an input: a vendor-routed report lands in the
33
+ * vendor's tenant scope and a workspace one in the caller's.
34
+ * - Bounded free text: `summary`, `body` and `evidence` are agent-authored, so
35
+ * the contract enforces maxima at the door as a validation failure, never a
36
+ * silent truncation. Truncating server-side would desynchronize the stored
37
+ * payload from the client's own key derivation.
38
+ * - `provenance` is best-effort: a permissive record, never a gate. A
39
+ * submission with none of it populated is still valid. Its two typed keys,
40
+ * `repo.target_repos` and `execution.rung`, are the only parts it validates.
41
+ * - Closed vocabularies are never guessed. Extending one is a change to this
42
+ * file, and the server is deployed before the clients that send the new
43
+ * value.
44
+ */
45
+
46
+ import { z } from "zod";
47
+
48
+ /** The single version the service EMITS on every receipt: the version a fresh
49
+ * client should send. Separate from the ingest endpoint's own version. */
50
+ export const YAKS_CONTRACT_VERSION = "2.0.0";
51
+
52
+ /**
53
+ * The write plane's version window. A tuple, not a bare
54
+ * `z.literal(YAKS_CONTRACT_VERSION)`, even with one member: a bare literal bump
55
+ * rejects every in-flight submission from a client that has not yet upgraded, the
56
+ * instant the new version deploys.
57
+ *
58
+ * When `YAKS_CONTRACT_VERSION` bumps: append the new version, don't replace the
59
+ * old one. Narrow back to a single element only after every client has moved.
60
+ */
61
+ export const ACCEPTED_YAKS_VERSIONS = ["2.0.0"] as const;
62
+
63
+ /** The write plane's version check. A response (`YakReceipt.contract_version`)
64
+ * keeps a literal: a receipt carries the one version that produced it. */
65
+ export const AcceptedYaksVersion = z.enum(ACCEPTED_YAKS_VERSIONS);
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Closed vocabularies
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Exactly these three, closed:
72
+ * issue : a harness defect the agent hit, confirmed or suspected
73
+ * suggest : an improvement to the harness (missing capability, better default)
74
+ * fatal : the harness itself is broken; clusters SEPARATELY from
75
+ * issue/suggest */
76
+ export const YakVerb = z.enum(["issue", "suggest", "fatal"]);
77
+ export type YakVerb = z.infer<typeof YakVerb>;
78
+
79
+ /** Where a submission files. `workspace` is the caller's own tenant. `vendor` is
80
+ * a report about eris itself, which flows into the vendor's tenant and never
81
+ * out of it. Required on the wire: a client always
82
+ * knows which it means, and a missing route is a client bug to refuse, not to
83
+ * default. */
84
+ export const YakRoute = z.enum(["workspace", "vendor"]);
85
+ export type YakRoute = z.infer<typeof YakRoute>;
86
+
87
+ /**
88
+ * The rung of the client's `execution_id` ladder that produced the value. The
89
+ * rungs hold genuinely different ids for one task, so which one fired decides
90
+ * the dedup grain a report lands at. The client owns the ladder's order and the
91
+ * environment it reads; the contract owns only the names.
92
+ *
93
+ * - v1's rungs, unchanged: `override` (an explicit id), `session` and
94
+ * `host-session` (the Claude Code session ids), `shell-session` (the
95
+ * invoking shell's id) and `shell` (the final fallback).
96
+ * - The generic harness rungs: `ai-agent` (the cross-vendor AI_AGENT
97
+ * identifier), `codex` and `cursor` (those harnesses' own session ids).
98
+ *
99
+ * The order of this list is not the ladder's order.
100
+ */
101
+ export const YAK_EXECUTION_ID_RUNGS = ["override", "session", "host-session", "shell-session", "shell", "ai-agent", "codex", "cursor"] as const;
102
+ export const YakExecutionIdRung = z.enum(YAK_EXECUTION_ID_RUNGS);
103
+ export type YakExecutionIdRung = z.infer<typeof YakExecutionIdRung>;
104
+
105
+ /** Processing state of a stored observation. The `data` blob (the original
106
+ * `YakSubmission`) never changes; this is the only part of an observation row
107
+ * that does. */
108
+ export const YakObservationStatus = z.enum(["pending", "processed", "failed"]);
109
+ export type YakObservationStatus = z.infer<typeof YakObservationStatus>;
110
+
111
+ /** A cluster's kind mirrors the verb partition it exists for: `fatal` clusters
112
+ * never merge with `report` clusters (the `issue`/`suggest` pool), because only
113
+ * `fatal` escalates and a shared lineage would have to pick one behaviour for
114
+ * both. */
115
+ export const YakClusterKind = z.enum(["fatal", "report"]);
116
+ export type YakClusterKind = z.infer<typeof YakClusterKind>;
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Bounded free text
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Length ceilings for the agent-authored fields. These are the only thing
124
+ * standing between one submission and pathological storage or match cost,
125
+ * because the fields are free text an agent generated, not a bounded enum or
126
+ * identifier. Enforced as a validation failure, never a `.slice()`.
127
+ */
128
+ /** ~one tracker issue-title line with the `[Yak][Fatal] ` prefix (13 chars)
129
+ * still comfortably inside a readable title. */
130
+ export const YAK_SUMMARY_MAX_LENGTH = 200;
131
+ /** A full incident narrative in prose. Matching runs against `summary`, not
132
+ * `body`; this bound is about storage and issue-description size. */
133
+ export const YAK_BODY_MAX_LENGTH = 4_000;
134
+ /** Evidence is often a pasted log excerpt or stack trace: it needs more room
135
+ * than prose, but is still bounded. */
136
+ export const YAK_EVIDENCE_MAX_LENGTH = 20_000;
137
+ /** `device_id` / `execution_id` are opaque identifiers, not free text, but they
138
+ * are caller-supplied strings that flow into the idempotency-key derivation:
139
+ * bounded for the same storage-hygiene reason, sized generously above any real
140
+ * identifier (UUID, hostname, PID composite). */
141
+ export const YAK_ID_MAX_LENGTH = 200;
142
+ /** The client-supplied (advisory) idempotency key. A sha256 hex digest is 64
143
+ * chars; headroom is for a future hash algorithm change on the client side,
144
+ * never load-bearing since the server always re-derives. */
145
+ export const YAK_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
146
+ /** A target-repo hint is a short `owner/name` identifier, not a URL or a
147
+ * paste. Sized generously above any real repo name. */
148
+ export const YAK_REPO_HINT_MAX_LENGTH = 200;
149
+ /** More hints than any single report spans repos. */
150
+ export const YAK_REPO_HINTS_MAX_COUNT = 20;
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Submission-adjacent shapes
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * A target-repo hint: `owner/name` by convention, and nested namespaces
158
+ * (`group/subgroup/project`) are legal because clients derive hints from git
159
+ * remotes. Bounded in length only. A shape check would turn a hint the client
160
+ * could not normalize into a refused report, and a hint is never worth that.
161
+ */
162
+ export const YakRepoHint = z.string().min(1).max(YAK_REPO_HINT_MAX_LENGTH);
163
+
164
+ /** The `repo` category of provenance: free-form like the rest, with one typed
165
+ * key. `target_repos` is an array even for one hint, for a report that spans
166
+ * repos. */
167
+ export const YakRepoProvenance = z
168
+ .object({
169
+ target_repos: z.array(YakRepoHint).max(YAK_REPO_HINTS_MAX_COUNT).optional(),
170
+ })
171
+ .catchall(z.unknown());
172
+ export type YakRepoProvenance = z.infer<typeof YakRepoProvenance>;
173
+
174
+ /** The `execution` category of provenance: session facts the client read, with
175
+ * one typed key, the rung that produced `execution_id`. */
176
+ export const YakExecutionProvenance = z
177
+ .object({
178
+ rung: YakExecutionIdRung.optional(),
179
+ })
180
+ .catchall(z.unknown());
181
+ export type YakExecutionProvenance = z.infer<typeof YakExecutionProvenance>;
182
+
183
+ /**
184
+ * Best-effort submission metadata. Six categories the client MAY populate when
185
+ * it knows them; each is a permissive record because the shape of "what a
186
+ * harness knows about its own process" varies by caller and this contract
187
+ * deliberately does not gate on it. `.catchall()` additionally accepts unknown
188
+ * top-level keys, so a client that learns an unforeseen provenance category does
189
+ * not have to wait on a contract change to send it.
190
+ *
191
+ * `git` may carry the committer's identity (git config) as a hint. The reporter
192
+ * is the tracker user the server derives from the token, never this.
193
+ *
194
+ * Never validated as a gate beyond the two typed keys above: a submission with
195
+ * `provenance` entirely absent is still a fully valid `YakSubmission`. This
196
+ * exists to enrich triage, not to constrain ingress.
197
+ */
198
+ export const YakProvenance = z
199
+ .object({
200
+ process: z.record(z.string(), z.unknown()).optional(),
201
+ device: z.record(z.string(), z.unknown()).optional(),
202
+ harness: z.record(z.string(), z.unknown()).optional(),
203
+ repo: YakRepoProvenance.optional(),
204
+ git: z.record(z.string(), z.unknown()).optional(),
205
+ execution: YakExecutionProvenance.optional(),
206
+ })
207
+ .catchall(z.unknown());
208
+ export type YakProvenance = z.infer<typeof YakProvenance>;
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Ingress
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * The ingress payload. Durable the instant it is accepted: ingress validates,
216
+ * derives the authoritative idempotency key, and persists the observation. No
217
+ * tracker work happens on this path.
218
+ */
219
+ export const YakSubmission = z.object({
220
+ schema_version: AcceptedYaksVersion,
221
+ verb: YakVerb,
222
+ route: YakRoute,
223
+ summary: z.string().min(1).max(YAK_SUMMARY_MAX_LENGTH),
224
+ body: z.string().min(1).max(YAK_BODY_MAX_LENGTH),
225
+ evidence: z.string().max(YAK_EVIDENCE_MAX_LENGTH).optional(),
226
+ device_id: z.string().min(1).max(YAK_ID_MAX_LENGTH),
227
+ execution_id: z.string().min(1).max(YAK_ID_MAX_LENGTH),
228
+ /** Client-derived, advisory only. The server always re-derives via
229
+ * `deriveIdempotencyKey` below and that value, never this one, is
230
+ * authoritative and is what the receipt reports. A mismatch is the server's
231
+ * concern to report and log, not this contract's. */
232
+ idempotency_key: z.string().max(YAK_IDEMPOTENCY_KEY_MAX_LENGTH).optional(),
233
+ provenance: YakProvenance.optional(),
234
+ });
235
+ export type YakSubmission = z.infer<typeof YakSubmission>;
236
+
237
+ /** What ingress returns for an accepted submission, whether newly accepted or a
238
+ * replay of one already seen (a replay returns the ORIGINAL receipt, not a
239
+ * fresh one). */
240
+ export const YakReceipt = z.object({
241
+ /** The stable id of the stored `YakObservation`. */
242
+ id: z.string(),
243
+ /** The server-derived, authoritative idempotency key: see
244
+ * `deriveIdempotencyKey` below. Always present, whether or not the client
245
+ * sent its own on the submission. */
246
+ idempotency_key: z.string(),
247
+ /** True when this insert lost a uniqueness race on the idempotency key to an
248
+ * already-stored observation, and the receipt describes that EARLIER
249
+ * observation, not a new one. */
250
+ replay: z.boolean(),
251
+ contract_version: z.literal(YAKS_CONTRACT_VERSION),
252
+ });
253
+ export type YakReceipt = z.infer<typeof YakReceipt>;
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Stored shapes: the read surface
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * One immutable submission plus its mutable processing state. `data` is written
261
+ * once at submit time and never rewritten; `status`, `cluster_id`, `attempts`,
262
+ * `next_attempt_at` and `last_error` are the only fields that change as the
263
+ * drain processes the row.
264
+ */
265
+ export const YakObservation = z.object({
266
+ id: z.string(),
267
+ idempotency_key: z.string(),
268
+ verb: YakVerb,
269
+ summary: z.string(),
270
+ /** Identity of the ingesting caller, stamped by the service from the bearer
271
+ * token, never from the payload. */
272
+ source: z.string(),
273
+ received_at: z.string().datetime(),
274
+ status: YakObservationStatus,
275
+ /** Absent until the drain assigns this observation to a cluster. */
276
+ cluster_id: z.string().optional(),
277
+ attempts: z.number().int().nonnegative(),
278
+ /** Absent unless a prior attempt failed and this row is backing off. */
279
+ next_attempt_at: z.string().datetime().optional(),
280
+ last_error: z.string().optional(),
281
+ /** The immutable submission this observation was created from. */
282
+ data: YakSubmission,
283
+ });
284
+ export type YakObservation = z.infer<typeof YakObservation>;
285
+
286
+ /** One point in a cluster's issue-linkage history. A cluster accumulates one of
287
+ * these each time it is linked to a tracker issue, including every time a
288
+ * closed predecessor forces a NEW issue plus a relation (relate, never reopen)
289
+ * rather than reusing the old one. */
290
+ export const YakClusterIssueLink = z.object({
291
+ issue_id: z.string(),
292
+ issue_identifier: z.string(), // e.g. "ABC-123"
293
+ linked_at: z.string().datetime(),
294
+ /** Set once the drain observes this issue as closed (the trigger for minting
295
+ * the NEXT link in the history). Absent while the linked issue is open. */
296
+ closed_observed_at: z.string().datetime().optional(),
297
+ });
298
+ export type YakClusterIssueLink = z.infer<typeof YakClusterIssueLink>;
299
+
300
+ /**
301
+ * A persistent cluster of observations. `current_issue_id` /
302
+ * `current_issue_identifier` name the issue new comments land on right now;
303
+ * `issue_history` is the full lineage, every issue this cluster has ever been
304
+ * linked to, in order, so closing an issue never loses the connection between
305
+ * the old filing and the new one.
306
+ */
307
+ export const YakCluster = z.object({
308
+ id: z.string(),
309
+ kind: YakClusterKind,
310
+ canonical_summary: z.string(),
311
+ keywords: z.array(z.string()).default([]),
312
+ observation_count: z.number().int().nonnegative(),
313
+ first_seen: z.string().datetime(),
314
+ last_seen: z.string().datetime(),
315
+ /** Absent only in the brief window between cluster creation and its first
316
+ * successful issue creation: every persisted cluster is expected to reach
317
+ * one. */
318
+ current_issue_id: z.string().optional(),
319
+ current_issue_identifier: z.string().optional(),
320
+ /** The full linkage history: see `YakClusterIssueLink`. Defaults to `[]` for
321
+ * a brand-new cluster not yet linked to anything. */
322
+ issue_history: z.array(YakClusterIssueLink).default([]),
323
+ });
324
+ export type YakCluster = z.infer<typeof YakCluster>;
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Idempotency-key derivation: the load-bearing rule, unchanged from v1
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * The canonical JSON encoding of the fields that make a submission a REPLAY of
332
+ * an earlier one. Key order is fixed (`summary`, `body`, `evidence`) because
333
+ * `JSON.stringify` serializes string keys in insertion order, so building this
334
+ * literal, rather than forwarding whatever key order the caller's own object
335
+ * had, is what makes the derivation stable however the caller built its
336
+ * `YakSubmission`.
337
+ */
338
+ export function canonicalPayload(submission: Pick<YakSubmission, "summary" | "body" | "evidence">): string {
339
+ return JSON.stringify({
340
+ summary: submission.summary,
341
+ body: submission.body,
342
+ evidence: submission.evidence,
343
+ });
344
+ }
345
+
346
+ /** Web-standard crypto only, so the package runs unchanged in a Worker, bun or
347
+ * node. */
348
+ async function sha256Hex(text: string): Promise<string> {
349
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
350
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
351
+ }
352
+
353
+ /**
354
+ * The authoritative idempotency-key derivation:
355
+ *
356
+ * canonicalPayload = JSON.stringify({ summary, body, evidence }) // fixed key order, no whitespace
357
+ * idempotency_key = sha256hex(`${device_id}\n${execution_id}\n${verb}\n${canonicalPayload}`)
358
+ *
359
+ * The `\n` delimiters are load-bearing, not decorative. Bare concatenation is
360
+ * PREFIX-AMBIGUOUS: `device_id="ab", execution_id="c"` and `device_id="a",
361
+ * execution_id="bc"` would serialize to the identical string and hash to the
362
+ * identical key, so two genuinely different callers would silently dedup into
363
+ * one. Joined with `\n` they serialize differently and so hash differently.
364
+ *
365
+ * Both halves call this: the client to compute its advisory key before it sends
366
+ * a request, and the server to compute the one value it stores and dedupes on.
367
+ * Re-deriving from the payload actually received, rather than trusting the
368
+ * client's value, is what makes the server authoritative. It is v1's derivation
369
+ * byte for byte, so a report replayed from a v1 client keys the same.
370
+ */
371
+ export function deriveIdempotencyKey(submission: Pick<YakSubmission, "device_id" | "execution_id" | "verb" | "summary" | "body" | "evidence">): Promise<string> {
372
+ const canonical = canonicalPayload(submission);
373
+ const material = `${submission.device_id}\n${submission.execution_id}\n${submission.verb}\n${canonical}`;
374
+ return sha256Hex(material);
375
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * plugins/index.ts — the plugins this build compiles in.
3
+ *
4
+ * src/main.ts composes this list with core's registry. Adding a plugin to the
5
+ * build is an import and an entry here; nothing under src/ changes. Order is
6
+ * the order of the plugin groups in `eris --help`. docs/dev/PLUGINS.md.
7
+ */
8
+
9
+ import type { ErisPlugin } from "../src/cli/plugin";
10
+ import yak from "./yak";
11
+
12
+ export const PLUGINS: readonly ErisPlugin[] = [yak];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * handlers.ts — `eris yak issue|suggest|fatal`,
3
+ * loaded only when `eris yak` dispatches (the manifest is ./index.ts).
4
+ *
5
+ * A thin dispatcher over the yak core (./yak.ts, `runYak`): this file
6
+ * owns the group usage, the unknown-verb block, and the per-call environment
7
+ * the core reads; the core owns each verb's grammar, its `--help`, and every
8
+ * receipt / warn / failure rendering. The three verbs share one body, so they
9
+ * share this one file rather than one file per sub.
10
+ *
11
+ * Invariants:
12
+ * - `ERIS_OUTPUT` carries the global `--output` (already stripped from argv)
13
+ * into the core, which reads it from its env.
14
+ * - `ERIS_YAK_SHELL_ID` is the pid of the caller's shell: the shim `exec`s
15
+ * bun, so this process's parent IS that shell. It is the
16
+ * `shell:<pid>` execution_id rung (plugins/yak/yak-identity.ts), constant across
17
+ * every call from one terminal so idempotency converges outside Claude Code.
18
+ * - A dependency the checkout cannot resolve WARNS and exits 0
19
+ * (`yak-deps-not-installed`), as a missing token does: the command that
20
+ * reports a broken harness must not become one more thing that stops the
21
+ * agent. Under `--output json` it is the bare record on stdout, stderr empty.
22
+ */
23
+
24
+ import { homedir } from "node:os";
25
+ import { CliExit, groupArgs, renderFailureBlock, type VerbContext } from "../../src/cli/plugin";
26
+ import plugin from "./index";
27
+
28
+ type RunYak = typeof import("./yak").runYak;
29
+
30
+ export interface YakVerbDeps {
31
+ /** The caller's shell pid. Defaults to this process's parent. */
32
+ ppid?: number;
33
+ cwd?: string;
34
+ /** Loads the core. Injected to exercise an uninstalled dependency. */
35
+ load?: () => Promise<RunYak>;
36
+ }
37
+
38
+ /** The group usage's flag block: each spelling, then its description lines. */
39
+ const USAGE_FLAGS: readonly (readonly [string, ...string[]])[] = [
40
+ ["--summary <text>", "required, <= 200 chars"],
41
+ ["--body <text> | --body-file <path|->", "required (- reads stdin)"],
42
+ ["--evidence <text> | --evidence-file <path|->", "optional, <= 20000 chars"],
43
+ ["--repro <command>", "optional, <= 2000 chars — a repro command", "stored with the cluster"],
44
+ ["--repo <owner/name>", "optional, repeatable — a target repo this", "report concerns; auto-derived from this", "process's own git remote when omitted"],
45
+ ["--no-redact", "skip the secret-scrub pass (loud warning)"],
46
+ ];
47
+
48
+ /** Every description starts two columns past the longest spelling. */
49
+ const USAGE_COLUMN = Math.max(...USAGE_FLAGS.map(([flag]) => flag.length)) + 2;
50
+ const USAGE_FLAG_BLOCK = USAGE_FLAGS.flatMap(([flag, ...lines]) => lines.map((line, i) => ` ${(i === 0 ? flag : "").padEnd(USAGE_COLUMN)}${line}\n`)).join("");
51
+
52
+ export const YAK_USAGE = `Usage: eris yak <command>
53
+
54
+ Commands:
55
+ issue A harness/tooling defect, confirmed or suspected
56
+ suggest An improvement to the harness: a missing capability or a better way of working
57
+ fatal The harness itself is broken
58
+
59
+ ${USAGE_FLAG_BLOCK}
60
+ Setup: 'eris login' writes ~/.eris/yaks.json ({"url", "token"}, mode 0600;
61
+ the url defaults to https://api.sibylline.group); or export ERIS_YAKS_URL /
62
+ ERIS_YAKS_TOKEN.
63
+
64
+ Run 'eris yak <command> --help' for the full flag reference.
65
+ `;
66
+
67
+ /** A bun "cannot resolve a package" error, read from its structured fields. */
68
+ function isMissingPackage(e: unknown): boolean {
69
+ if (typeof e !== "object" || e === null) return false;
70
+ const { code, specifier } = e as Record<string, unknown>;
71
+ return (
72
+ (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") &&
73
+ typeof specifier === "string" &&
74
+ !/^(\.|\/|[a-z][a-z0-9+.-]*:)/i.test(specifier)
75
+ );
76
+ }
77
+
78
+ function depsMissing(verb: string, ctx: VerbContext): number {
79
+ const what = "eris's node_modules is missing (or incomplete) — 'eris yak' cannot run its TypeScript core without it";
80
+ if (ctx.format === "json") {
81
+ const record = { verb, delivered: false, redactions: { count: 0, classes: [], skipped: false }, error: { slug: "yak-deps-not-installed", reason: what } };
82
+ ctx.stdout.write(`${JSON.stringify(record)}\n`);
83
+ } else {
84
+ ctx.stderr.write(renderFailureBlock({ slug: "yak-deps-not-installed", what, fix: `from ${ctx.erisDir}, run: bun install` }, ctx.erisDir));
85
+ }
86
+ return 0;
87
+ }
88
+
89
+ export async function runYakVerb(args: string[], ctx: VerbContext, deps: YakVerbDeps = {}): Promise<number> {
90
+ const g = groupArgs(plugin.rows, "yak", args);
91
+ if (g.kind === "bare" || g.kind === "help") {
92
+ ctx.stdout.write(YAK_USAGE);
93
+ return 0;
94
+ }
95
+ if (g.kind === "unknown") {
96
+ ctx.stderr.write(
97
+ renderFailureBlock(
98
+ { slug: "yak-unknown-verb", what: `unknown yak command '${g.sub}'`, fix: "use one of: issue, suggest, fatal\nrun 'eris yak --help' for the full command list" },
99
+ ctx.erisDir,
100
+ ),
101
+ );
102
+ throw new CliExit(1);
103
+ }
104
+ const verb = g.sub;
105
+
106
+ let runYak: RunYak;
107
+ try {
108
+ runYak = await (deps.load ?? (async () => (await import("./yak")).runYak))();
109
+ } catch (e) {
110
+ if (isMissingPackage(e)) return depsMissing(verb, ctx);
111
+ throw e;
112
+ }
113
+
114
+ const result = await runYak({
115
+ argv: [verb, ...g.rest],
116
+ env: {
117
+ ...ctx.env,
118
+ ERIS_DIR: ctx.erisDir,
119
+ ERIS_OUTPUT: ctx.format,
120
+ ERIS_YAK_SHELL_ID: String(deps.ppid ?? process.ppid),
121
+ },
122
+ home: ctx.env.HOME ?? homedir(),
123
+ cwd: deps.cwd,
124
+ });
125
+ if (result.stdout.length > 0) ctx.stdout.write(result.stdout);
126
+ if (result.stderr.length > 0) ctx.stderr.write(result.stderr);
127
+ return result.exitCode;
128
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The yak plugin's manifest: `eris yak issue|suggest|fatal`, and `eris login` /
3
+ * `eris logout`, which manage the bearer `eris yak` reads, as registry rows
4
+ * plus handler thunks (docs/dev/PLUGINS.md).
5
+ *
6
+ * Read on every command line, `eris completion` included: rows are inline
7
+ * data, and the handler module loads only when `eris yak` dispatches.
8
+ */
9
+
10
+ import { definePlugin } from "../../src/cli/plugin";
11
+
12
+ /** The one grammar the three verbs share. */
13
+ const YAK_FLAGS = [
14
+ { name: "--summary", takesValue: true },
15
+ { name: "--body", takesValue: true },
16
+ { name: "--body-file", takesValue: true, file: true },
17
+ { name: "--evidence", takesValue: true },
18
+ { name: "--evidence-file", takesValue: true, file: true },
19
+ { name: "--repro", takesValue: true },
20
+ { name: "--repo", takesValue: true, repeatable: true },
21
+ { name: "--no-redact", takesValue: false },
22
+ ] as const;
23
+
24
+ export default definePlugin({
25
+ name: "yak",
26
+ rows: {
27
+ yak: { impl: "ts", summary: "Land a durable observation with a Yaks endpoint (issue, suggest, fatal)" },
28
+ "yak fatal": { summary: "The harness itself is broken", json: true, flags: YAK_FLAGS },
29
+ "yak issue": { summary: "A harness/tooling defect, confirmed or suspected", json: true, flags: YAK_FLAGS },
30
+ "yak suggest": { summary: "An improvement to the harness: a missing capability or a better way of working", json: true, flags: YAK_FLAGS },
31
+ login: {
32
+ impl: "ts",
33
+ summary: "Sign this machine in with a device code; writes the token 'eris yak' uses",
34
+ usage: "[--url <url>] [--no-browser]",
35
+ flags: [
36
+ { name: "--url", takesValue: true },
37
+ { name: "--no-browser", takesValue: false },
38
+ ],
39
+ },
40
+ logout: { impl: "ts", summary: "Remove this machine's token from ~/.eris/yaks.json" },
41
+ },
42
+ handlers: {
43
+ yak: async () => (await import("./handlers")).runYakVerb,
44
+ login: async () => (await import("./login")).runLogin,
45
+ logout: async () => (await import("./login")).runLogout,
46
+ },
47
+ jsonFix: ` eris yak issue|suggest|fatal {verb, delivered, id?, idempotency_key?, replay?,
48
+ contract_version?, key_mismatch?, redactions, error?}
49
+ (nothing on stderr, unlike every other surface here)
50
+ `,
51
+ });