@drkmttr/adhere 0.18.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +62 -1575
  2. package/package.json +18 -15
  3. package/presets/README.md +140 -0
  4. package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
  7. package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
  8. package/skills/adhere/SKILL.md +194 -29
  9. package/skills/adhere-fix/SKILL.md +1 -1
  10. package/skills/adhere-setup/SKILL.md +60 -26
  11. package/src/cli.ts +334 -36
  12. package/src/config.ts +173 -142
  13. package/src/create/agents.ts +149 -0
  14. package/src/create/ask.ts +363 -0
  15. package/src/create/examples.ts +94 -0
  16. package/src/create/look.ts +191 -0
  17. package/src/create/oxlint-trial.ts +464 -0
  18. package/src/create/oxlint.ts +1012 -0
  19. package/src/create/trial-review.ts +562 -0
  20. package/src/create/trial.ts +611 -0
  21. package/src/create/wizard.ts +466 -0
  22. package/src/index.ts +2 -0
  23. package/src/init.ts +171 -41
  24. package/src/install.ts +296 -94
  25. package/src/instructions.ts +98 -0
  26. package/src/markdown.ts +15 -11
  27. package/src/oxlint/catalog.json +7844 -0
  28. package/src/oxlint/catalog.ts +72 -0
  29. package/src/oxlint/shared.ts +140 -0
  30. package/src/readme-guide.ts +431 -0
  31. package/src/readme.ts +291 -0
  32. package/src/rules.ts +42 -45
  33. package/src/services/AdhereConfig.ts +67 -21
  34. package/src/services/AuditCache.ts +176 -169
  35. package/src/services/Credentials.ts +14 -8
  36. package/src/services/Jev.reads.test.ts +22 -0
  37. package/src/services/Jev.ts +35 -13
  38. package/src/services/SourceWalker.ts +12 -5
  39. package/src/wording.ts +4 -4
  40. package/src/workflows/audit.ts +85 -14
  41. package/src/workflows/format.ts +98 -1
  42. package/src/workspaces.test.ts +37 -1
  43. package/src/workspaces.ts +121 -12
@@ -25,10 +25,10 @@ the `packageManager` field in `package.json` names it; with neither, **Ask**.
25
25
 
26
26
  | Manager | Add adhere | Run adhere |
27
27
  | ------- | -------------------------------- | ------------------ |
28
- | bun | `bun add -D @drkmttr/adhere` | `bunx adhere` |
28
+ | bun | `bun add -D @drkmttr/adhere` | `bun run adhere` |
29
29
  | pnpm | `pnpm add -D @drkmttr/adhere` | `pnpm exec adhere` |
30
30
  | yarn | `yarn add -D @drkmttr/adhere` | `yarn adhere` |
31
- | npm | `npm install -D @drkmttr/adhere` | `npx adhere` |
31
+ | npm | `npm install -D @drkmttr/adhere` | `npm exec adhere` |
32
32
 
33
33
  In a repo with a `package.json`, add adhere as a dev dependency with its
34
34
  manager, so the repo pins the version the team and CI run. From then on,
@@ -37,12 +37,14 @@ wherever this skill says `adhere`, run it the manager's way, as in
37
37
 
38
38
  With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
39
39
 
40
- Default lint reads code and `workspacePackages` without starting a compiler.
40
+ Default lint reads code and `workspacePackages` without starting a compiler,
41
+ and leaves out bundled preset rules that declare `symbols` or `references`.
41
42
  `typeAware` defaults to `false`; `typeAware: true` or `--type-aware` opts into
42
- both `symbols` and `references`, regardless of `reads`. `reads` now controls
43
- workspace context only, defaulting to `["workspacePackages"]`; legacy native
44
- reader entries alone do not opt in. Code alone uses `reads: []` with type
45
- awareness off. Explain the extra context/cost and **Ask** before opting in.
43
+ both `symbols` and `references`, regardless of `reads`, and includes those
44
+ rules. `reads` now controls workspace context only, defaulting to
45
+ `["workspacePackages"]`; legacy native reader entries alone do not opt in.
46
+ Code alone uses `reads: []` with type awareness off. Explain the extra
47
+ context/cost and **Ask** before opting in.
46
48
 
47
49
  With type awareness enabled, native TypeScript starts only during execution,
48
50
  after confirmation, when pending judgments or cached findings needing location
@@ -56,18 +58,19 @@ and validate do not start the compiler.
56
58
  In a repo without a `package.json`, inspect any `adhere` on PATH with
57
59
  `adhere --version`, then **Ask** whether to use the npm/Bun package launcher
58
60
  or a raw executable for the same default compiler-free audit. The raw release
59
- (`adhere-Windows-x86_64.exe` on Windows) needs no Node or package manager and
60
- runs with workspace context without special configuration. It cannot run a
61
- type-aware audit: use the package launcher or a checkout if the user opts in.
62
- Do not silently turn off an explicitly chosen `typeAware` setting. Only fetch
63
- the raw release after the user chooses that alternative:
61
+ needs no Node or package manager and runs with workspace context without
62
+ special configuration. It cannot run a type-aware audit: use the package
63
+ launcher or a checkout if the user opts in. Do not silently turn off an
64
+ explicitly chosen `typeAware` setting. Only fetch the raw release after the
65
+ user chooses that alternative. On macOS and Linux:
64
66
 
65
67
  ```sh
66
- curl -fsSL --create-dirs -o ~/.local/bin/adhere \
67
- https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
68
- chmod +x ~/.local/bin/adhere
68
+ curl -fsSL https://github.com/darkmatter/adhere/raw/main/scripts/install.sh | sh
69
69
  ```
70
70
 
71
+ On Windows, download `adhere-Windows-x86_64.exe` from
72
+ https://github.com/darkmatter/adhere/releases/latest.
73
+
71
74
  Validate and lint's paid requests need a TypeSafe AI API key: `TYPESAFE_API_KEY`,
72
75
  or one saved by `adhere login`, which the user runs; never ask for the key in
73
76
  chat or print it. Compiler-free planning with `--limit 0` needs no key.
@@ -90,7 +93,10 @@ from and the evidence.
90
93
 
91
94
  Keep only conventions that need judgment: about intent, shape, or placement.
92
95
  Drop any a regex, an import check, or the type checker could flag every time,
93
- and note where it belongs instead. Drop any a preset below already covers.
96
+ and note where it belongs instead: a native Oxlint rule, or a convention a
97
+ small syntax-only Oxlint JS plugin could decide. `adhere rules create` routes
98
+ those two, and leaves the rest to adhere. Drop any a preset below already
99
+ covers.
94
100
 
95
101
  **Presets.** Match the stack: `typescript` for TypeScript, `react` for React,
96
102
  `security` for code that handles secrets, SQL, shell commands, or paths,
@@ -99,9 +105,10 @@ and note where it belongs instead. Drop any a preset below already covers.
99
105
  https://github.com/darkmatter/adhere/tree/main/presets, to describe them and
100
106
  to avoid drafting duplicates.
101
107
 
102
- **A shared repo.** **Ask** whether the organization keeps adhere rules in a
103
- repo. If it does, `adhere list org/repo` prints its rules with their
104
- descriptions.
108
+ **A shared repo.** **Ask** whether the organization keeps adhere instructions,
109
+ rules, or lint settings in a repo. If it does, `adhere list org/repo` prints
110
+ its rules with their descriptions; its instruction files and `oxlint/` package
111
+ are separate, and `adhere install` offers the instruction files too.
105
112
 
106
113
  ## 3. Draft the repo's rules
107
114
 
@@ -130,7 +137,8 @@ Presets
130
137
  From acme/standards
131
138
  5. data/brand-ports A port must be a branded, range-checked integer, never a bare number.
132
139
  Dropped as a linter's job
133
- - "no default exports": eslint import/no-default-export
140
+ - "no default exports": native oxlint import/no-default-export
141
+ - "no local date formatting": custom syntax-only oxlint plugin
134
142
  ```
135
143
 
136
144
  For a preset, offer the whole preset or its topics. Show a draft's full text
@@ -151,8 +159,30 @@ the wording of any. Revise drafts the user wants changed and show them again.
151
159
  workspace context, not native maps. Only replace `reads` if the user chooses
152
160
  to limit workspace context. If requested type awareness lacks compiler resources,
153
161
  use the package launcher or a checkout; do not silently turn it off.
154
- - Install chosen shared rules: `adhere install org/repo/<id>` for each, or
155
- `adhere install org/repo` for all of them. They land in `.adhere/rules/org/repo/`.
162
+ - Copy chosen shared instructions and rules: run `adhere install org/repo` and
163
+ let the user pick at the terminal, or add `--all` only when they chose
164
+ everything (the non-interactive path). Name an exact rule to copy it without
165
+ asking. Instruction files land in `.adhere/instructions/org/repo/` and rules
166
+ in `.adhere/rules/org/repo/`; `adhere postinstall` renders the instructions,
167
+ copied files first.
168
+ - **Ask** before adding the shared repo's lint package: pin it as a git
169
+ dependency at the repo-wide tag, then spread `@org/repo/oxlint` into the
170
+ project's Vite+ lint block or `oxlint.config.ts`. It is a package, not an
171
+ `adhere install` copy. Project plugins live in `.adhere/oxlint/`.
172
+ - For a chosen linter candidate, run `adhere rules create "<convention>"` and
173
+ follow its route. A native Oxlint rule must be one of adhere's catalog ids;
174
+ an unknown id is rejected. It must carry the rule's exact option values when
175
+ it takes options; adhere prints `["error", ...options]`, never a bare
176
+ `"error"` that would enforce nothing. It prints the config for the user's lint
177
+ config, and adhere does not edit it. A custom syntax-only plugin is shown with
178
+ valid and invalid examples and runs nothing until approved, then its tests
179
+ use the project's Oxlint through `oxlint/plugins-dev` or
180
+ `vite-plus/lint/plugins-dev`. That needs its own Oxlint or Vite+ and Node 22
181
+ or newer on `PATH`; Oxlint's RuleTester refuses Bun and older Node, and
182
+ without it nothing is written and no repair is attempted. One failed run goes
183
+ back to the agent for repair; the user approves the repaired plugin, and it
184
+ runs again. Project files go to `.adhere/oxlint/` as `.mjs`; shared files go
185
+ to `oxlint/plugins/` as `.js` on a branch for a pull request.
156
186
  - Add `exclude` globs for generated or vendored code the default skips miss.
157
187
 
158
188
  ## 6. Validate
@@ -163,8 +193,10 @@ adhere validate
163
193
 
164
194
  It reports rules worded against the tips, rules a linter could check, and
165
195
  contradictions between rules that apply to the same files. Fix the wording
166
- yourself. For a linter-checkable rule or a contradiction, **Ask** the user
167
- which to keep, and apply it. Run it again until it reports no contradictions.
196
+ yourself. For a linter-checkable rule, **Ask** whether to move it to a native
197
+ Oxlint rule or an approved syntax-only plugin, or keep it as an adhere rule.
198
+ For a contradiction, **Ask** which rule to keep and apply the change. Run it
199
+ again until it reports no contradictions.
168
200
 
169
201
  ## 7. The first lint
170
202
 
@@ -401,9 +433,11 @@ This needs the cache committed on the base branch first.
401
433
 
402
434
  Tell the user:
403
435
 
404
- - the rules imported, by source, and where they are;
436
+ - the rules imported, by source, and where they are, plus any shared
437
+ instruction files copied and the `oxlint/` package pinned;
405
438
  - the presets and shared repo named in the config or installed;
406
- - the candidates dropped as a linter's job, with where each belongs;
439
+ - the candidates routed to native Oxlint, an approved plugin, or an adhere
440
+ rule, and which config registers each;
407
441
  - the last lint's findings, and what was fixed, made a warning, or suppressed;
408
442
  - the CI workflow and whether the secret is set;
409
443
  - what is left for them: pushing, setting the secret, fixing remaining findings.
package/src/cli.ts CHANGED
@@ -24,21 +24,49 @@ import {
24
24
  type Progress,
25
25
  progressAfter,
26
26
  progressLine,
27
+ renderJson,
28
+ renderMarkdown,
29
+ renderRuleReports,
27
30
  sendQuestion,
28
31
  } from "#workflows/format.ts";
29
32
  import type { Flags } from "#config.ts";
30
33
  import { findContradictions, formatContradictions } from "#contradictions.ts";
34
+ import { createRule } from "#create/wizard.ts";
35
+ import { AGENTS } from "#create/agents.ts";
31
36
  import { type Install, initProject } from "#init.ts";
32
- import { installRules, listRules, parseSource } from "#install.ts";
37
+ import {
38
+ type CatalogMatch,
39
+ type InstallPicks,
40
+ type Source,
41
+ catalogOf,
42
+ cloned,
43
+ describeRules,
44
+ installSelected,
45
+ instructionPath,
46
+ listRules,
47
+ matchOf,
48
+ parseSource,
49
+ rulePath,
50
+ } from "#install.ts";
51
+ import { renderInstructions } from "#instructions.ts";
33
52
  import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
34
53
  import { type PresetName, presetNames } from "#presets.ts";
35
54
  import type { SavedKey } from "#providers/provider.ts";
55
+ import {
56
+ ReadmeUnavailable,
57
+ standardsRepoOf,
58
+ isSharedRepo,
59
+ originUrlAt,
60
+ refreshRemoteReadme,
61
+ refreshSharedReadme,
62
+ } from "#readme.ts";
36
63
  import { globalRuleSet } from "#rules.ts";
37
64
  import { formatStrays, straysAmong } from "#wording.ts";
38
65
  import {
39
66
  Console,
40
67
  Effect,
41
68
  Exit,
69
+ FileSystem,
42
70
  Layer,
43
71
  LogLevel,
44
72
  Option,
@@ -49,8 +77,9 @@ import {
49
77
  Schema,
50
78
  Stdio,
51
79
  Stream,
80
+ Terminal,
52
81
  } from "effect";
53
- import { Argument, Command, Flag, Prompt } from "effect/cli";
82
+ import { Argument, CliError, Command, Flag, Prompt } from "effect/cli";
54
83
  // A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
55
84
  // contents become a string at bundle time, so the compiled binary carries the
56
85
  // skill without an --asset flag. `skills/` sits beside `src/`, outside the `#`
@@ -121,7 +150,7 @@ const sufficiencyThreshold = Flag.Finite("sufficiency-threshold").pipe(
121
150
  const typeAware = Flag.Boolean("type-aware").pipe(
122
151
  Flag.optional,
123
152
  Flag.withDescription(
124
- "Include compiler-resolved symbols and declaration excerpts. Off by default.",
153
+ "Include compiler-resolved symbols and declaration excerpts, and the bundled rules that declare them. Off by default.",
125
154
  ),
126
155
  );
127
156
 
@@ -246,9 +275,43 @@ export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
246
275
  /** `adhere lint`: the audit. */
247
276
  export const lintCommand = Command.make(
248
277
  "lint",
249
- { preset, threshold, sufficiencyThreshold, typeAware, yes, limit, rpm, filter, denyWarnings },
278
+ {
279
+ preset,
280
+ threshold,
281
+ sufficiencyThreshold,
282
+ typeAware,
283
+ yes,
284
+ limit,
285
+ rpm,
286
+ filter,
287
+ denyWarnings,
288
+ json: Flag.Boolean("json").pipe(
289
+ Flag.withDefault(false),
290
+ Flag.withDescription(
291
+ "Write one JSON file per violated rule to .adhere/reports/. Use --stdout for one combined report instead.",
292
+ ),
293
+ ),
294
+ md: Flag.Boolean("md").pipe(
295
+ Flag.withDefault(false),
296
+ Flag.withDescription(
297
+ "Write one Markdown file per violated rule to .adhere/reports/. Use --stdout for one combined report instead.",
298
+ ),
299
+ ),
300
+ stdout: Flag.Boolean("stdout").pipe(
301
+ Flag.withDefault(false),
302
+ Flag.withDescription(
303
+ "Print all findings together instead of writing files. Select either --json or --md for a formatted report.",
304
+ ),
305
+ ),
306
+ },
250
307
  (input) =>
251
308
  Effect.gen(function* () {
309
+ if (input.stdout && input.json && input.md) {
310
+ return yield* CliError.UserError.make({
311
+ cause:
312
+ "Choose either --json or --md with --stdout; both formats can be written to files together.",
313
+ });
314
+ }
252
315
  const stdio = yield* Stdio.Stdio;
253
316
  const plan = yield* planAudit({ limit: Option.getOrUndefined(input.limit) });
254
317
  const limits = { filter: input.filter, rpm: Option.getOrUndefined(input.rpm) };
@@ -258,8 +321,7 @@ export const lintCommand = Command.make(
258
321
  if (input.filter.length === 0) yield* pruneCache(plan);
259
322
  return;
260
323
  }
261
- // A prompt draws on stdout, so it needs a terminal at both ends: none when
262
- // the report is redirected, or in CI.
324
+ // Keep prompting limited to interactive runs, including when JSON is requested.
263
325
  const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
264
326
  if (
265
327
  (plan.requests > 0 || (plan.cachedLocateRequests ?? 0) > 0) &&
@@ -268,6 +330,11 @@ export const lintCommand = Command.make(
268
330
  ) {
269
331
  const send = yield* Prompt.run(
270
332
  Prompt.Confirm({ message: sendQuestion(plan), initial: true }),
333
+ ).pipe(
334
+ Effect.updateService(Terminal.Terminal, (terminal) => ({
335
+ ...terminal,
336
+ display: toStderr,
337
+ })),
271
338
  );
272
339
  if (!send) {
273
340
  yield* toStderr("Nothing sent.\n");
@@ -283,16 +350,33 @@ export const lintCommand = Command.make(
283
350
  if (input.filter.length === 0) yield* pruneCache(plan);
284
351
  const color = yield* stdio.stdoutIsTerminal;
285
352
  const path = yield* Path.Path;
286
- // One write: separate Console.log calls have interleaved out of order here.
287
- yield* Console.log(
288
- render(result, {
289
- color,
290
- root: path.resolve(),
291
- threshold: plan.threshold,
292
- sufficiencyThreshold: plan.sufficiencyThreshold,
293
- ...(plan.provider === undefined ? {} : { provider: plan.provider }),
294
- }).join("\n"),
295
- );
353
+ const options = {
354
+ color,
355
+ root: path.resolve(),
356
+ threshold: plan.threshold,
357
+ sufficiencyThreshold: plan.sufficiencyThreshold,
358
+ ...(plan.provider === undefined ? {} : { provider: plan.provider }),
359
+ };
360
+ if (!input.stdout && (input.json || input.md)) {
361
+ const fs = yield* FileSystem.FileSystem;
362
+ const directory = path.join(options.root, ".adhere", "reports");
363
+ const reports = renderRuleReports(result, input, options);
364
+ yield* fs.remove(directory, { recursive: true, force: true });
365
+ yield* fs.makeDirectory(directory, { recursive: true });
366
+ for (const [name, report] of reports) {
367
+ yield* fs.writeFileString(path.join(directory, name), report);
368
+ }
369
+ yield* toStderr(`Wrote ${reports.size} report files to .adhere/reports/.\n`);
370
+ } else {
371
+ // One write: separate Console.log calls have interleaved out of order here.
372
+ yield* Console.log(
373
+ input.json
374
+ ? renderJson(result, options)
375
+ : input.md
376
+ ? renderMarkdown(result, options)
377
+ : render(result, options).join("\n"),
378
+ );
379
+ }
296
380
  // Only errors fail the run, unless --deny-warnings: a warning is for a nit or a noisy rule.
297
381
  const failed = failing(result.findings, input.denyWarnings);
298
382
  if (failed.length > 0) {
@@ -326,7 +410,7 @@ export const validateLayer = (flags: Flags) =>
326
410
 
327
411
  /**
328
412
  * `adhere validate`: everything `lint` loads, then how each rule's wording
329
- * differs from the README's rule writing tips, then the linter check's tallies
413
+ * differs from the docs' rule writing tips, then the linter check's tallies
330
414
  * from lint, then Jev on whether any two rules that apply to the same files
331
415
  * contradict. Only a contradiction fails the run: the tips and the linter
332
416
  * check are advice. A config, rule file, or preset that does not decode
@@ -356,7 +440,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
356
440
  }),
357
441
  ).pipe(
358
442
  Command.withDescription(
359
- "Load the config, rule files, and presets the way lint does, check each rule's wording against the README's rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
443
+ "Load the config, rule files, and presets the way lint does, check each rule's wording against the docs' rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
360
444
  ),
361
445
  Command.provide((input) => validateLayer({ presets: input.preset })),
362
446
  );
@@ -379,7 +463,7 @@ const shared = Flag.String("shared").pipe(
379
463
  ),
380
464
  Flag.optional,
381
465
  Flag.withDescription(
382
- "Scaffold a repo of rules other repos copy with adhere install, named org/repo on GitHub: example rules, a README, a CI workflow that runs adhere validate, and an alchemy.run.ts that creates the repo, public unless you make it private when asked, and its TYPESAFE_API_KEY secret.",
466
+ "Scaffold a repo of instructions, rules, and a lint package other repos use, named org/repo on GitHub: example instructions and rules, a README, a CI workflow that runs adhere validate, and an alchemy.run.ts that creates the repo, public unless you make it private when asked, and its TYPESAFE_API_KEY secret.",
383
467
  ),
384
468
  );
385
469
 
@@ -408,7 +492,7 @@ export const initCommand = Command.make("init", { force, shared }, (input) =>
408
492
  }),
409
493
  ).pipe(
410
494
  Command.withDescription(
411
- "Scaffold .adhere/config.ts and example Markdown rules, and add @drkmttr/adhere to devDependencies with the project's package manager.",
495
+ "Scaffold .adhere/config.ts, .adhere/tsconfig.json, and example Markdown rules, and add @drkmttr/adhere to devDependencies with the project's package manager.",
412
496
  ),
413
497
  );
414
498
 
@@ -418,7 +502,7 @@ const source = Argument.String("source").pipe(
418
502
  (spec) => `org/repo, then optionally a topic or rule and #ref, not ${spec}`,
419
503
  ),
420
504
  Argument.withDescription(
421
- "A GitHub repo with rules in .adhere/rules/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
505
+ "A GitHub repo with instructions in .adhere/instructions/ and rules in .adhere/rules/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
422
506
  ),
423
507
  );
424
508
 
@@ -437,30 +521,136 @@ export const listCommand = Command.make("list", { source }, (input) =>
437
521
  ),
438
522
  );
439
523
 
440
- /** `adhere install org/repo`: another repo's rules, copied into this one's `.adhere/rules/`. */
524
+ class InstallUnavailable extends Schema.TaggedError<InstallUnavailable>()("InstallUnavailable", {
525
+ message: Schema.String,
526
+ }) {}
527
+
528
+ /**
529
+ * What `adhere install` copies: one exact rule without asking, everything
530
+ * when `--all` is given, or what the user picks at a terminal. Without a
531
+ * terminal, a choice refuses and says what `--all` would copy and which exact
532
+ * rule can be named instead. A choice already installed is preselected, so
533
+ * running install again offers to bring it up to date, and deselecting it
534
+ * leaves it as it is.
535
+ */
536
+ const choosePicks = Effect.fn("install.choosePicks")(function* (
537
+ source: Source,
538
+ match: CatalogMatch,
539
+ options: { readonly all: boolean; readonly root: string; readonly clone: string },
540
+ ) {
541
+ if (match.kind === "direct") {
542
+ return { instructions: [], rules: [match.rule.id] } satisfies InstallPicks;
543
+ }
544
+ if (options.all) {
545
+ return {
546
+ instructions: match.instructions,
547
+ rules: match.rules.map(({ id }) => id),
548
+ } satisfies InstallPicks;
549
+ }
550
+ const pinned = source.ref === undefined ? "" : `#${source.ref}`;
551
+ const stdio = yield* Stdio.Stdio;
552
+ if (!(yield* stdio.stdinIsTerminal) || !(yield* stdio.stdoutIsTerminal)) {
553
+ const [first] = match.rules;
554
+ const ids = [
555
+ ...match.instructions.map((file) => `instructions/${file}`),
556
+ ...match.rules.map(({ id }) => id),
557
+ ];
558
+ const spec = `${source.repo}${source.rule === "" ? "" : `/${source.rule}`}${pinned}`;
559
+ const exact =
560
+ first === undefined
561
+ ? ""
562
+ : `, or name one rule exactly, as in ${source.repo}/${first.id}${pinned}`;
563
+ return yield* new InstallUnavailable({
564
+ message: `adhere install ${spec}: no terminal to choose from. Pass --all to copy every match (${ids.join(", ")})${exact}.`,
565
+ });
566
+ }
567
+ const fs = yield* FileSystem.FileSystem;
568
+ const path = yield* Path.Path;
569
+ type Pick =
570
+ | { readonly kind: "instruction"; readonly path: string }
571
+ | { readonly kind: "rule"; readonly id: string };
572
+ const instructionChoices = yield* Effect.forEach(match.instructions, (file) =>
573
+ fs.exists(path.join(options.root, instructionPath(source, file))).pipe(
574
+ Effect.orElseSucceed(() => false),
575
+ Effect.map((selected) => ({
576
+ title: `instructions/${file}`,
577
+ value: { kind: "instruction", path: file } as const,
578
+ selected,
579
+ })),
580
+ ),
581
+ );
582
+ const described = yield* describeRules(source, options.clone, match.rules);
583
+ const ruleChoices = yield* Effect.forEach(described, (rule) =>
584
+ fs.exists(path.join(options.root, rulePath(source, rule.id))).pipe(
585
+ Effect.orElseSucceed(() => false),
586
+ Effect.map((selected) => ({
587
+ title: rule.id,
588
+ description: rule.description,
589
+ value: { kind: "rule", id: rule.id } as const,
590
+ selected,
591
+ })),
592
+ ),
593
+ );
594
+ const picks: ReadonlyArray<Pick> = yield* Prompt.run(
595
+ Prompt.MultiSelect<Pick>({
596
+ message:
597
+ source.rule === ""
598
+ ? `What should adhere copy from ${source.repo}${pinned}?`
599
+ : `Which rules under ${source.repo}/${source.rule}${pinned} should adhere copy?`,
600
+ choices: [...instructionChoices, ...ruleChoices],
601
+ }),
602
+ );
603
+ return {
604
+ instructions: picks.flatMap((pick) => (pick.kind === "instruction" ? [pick.path] : [])),
605
+ rules: picks.flatMap((pick) => (pick.kind === "rule" ? [pick.id] : [])),
606
+ } satisfies InstallPicks;
607
+ });
608
+
609
+ /** `adhere install org/repo`: another repo's instructions and rules, copied into this one's `.adhere/`. */
441
610
  export const installCommand = Command.make(
442
611
  "install",
443
612
  {
444
613
  source,
445
614
  force: Flag.Boolean("force").pipe(
446
615
  Flag.withDefault(false),
447
- Flag.withDescription("Replace rules already in .adhere/rules/, directory and all."),
616
+ Flag.withDescription(
617
+ "Replace instruction files and rules already in .adhere/, file by file and directory by directory.",
618
+ ),
619
+ ),
620
+ all: Flag.Boolean("all").pipe(
621
+ Flag.withDefault(false),
622
+ Flag.withDescription(
623
+ "Copy every instruction file and rule the source names, without asking. Needs no terminal.",
624
+ ),
448
625
  ),
449
626
  },
450
627
  (input) =>
451
- Effect.gen(function* () {
452
- const path = yield* Path.Path;
453
- const result = yield* installRules(path.resolve(), input.source, { force: input.force });
454
- yield* Console.log(
455
- [
456
- result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
457
- result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
458
- ].join("\n"),
459
- );
460
- }),
628
+ Effect.scoped(
629
+ Effect.gen(function* () {
630
+ const path = yield* Path.Path;
631
+ const root = path.resolve();
632
+ const clone = yield* cloned(input.source);
633
+ const catalog = yield* catalogOf(input.source, clone);
634
+ const match = yield* matchOf(input.source, catalog);
635
+ const picks = yield* choosePicks(input.source, match, {
636
+ all: input.all,
637
+ root,
638
+ clone,
639
+ });
640
+ const result = yield* installSelected(root, input.source, clone, picks, {
641
+ force: input.force,
642
+ });
643
+ yield* Console.log(
644
+ [
645
+ result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
646
+ result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
647
+ ].join("\n"),
648
+ );
649
+ }),
650
+ ),
461
651
  ).pipe(
462
652
  Command.withDescription(
463
- "Copy the rules in a GitHub repo's .adhere/rules/, or one topic or rule of them, each its whole directory, into this repo's .adhere/rules/<org>/<repo>/. They are this repo's own rules from then on, to edit or keep in step by hand, and a copied RULE.ts runs whenever lint does. A rule already there is skipped unless --force.",
653
+ "Copy instruction files and rules from a GitHub repo's .adhere/ into this repo's .adhere/instructions/<org>/<repo>/ and .adhere/rules/<org>/<repo>/. At a terminal, choose which instruction files and rules to copy; name a topic for its rules, or name one rule exactly to copy it without asking. --all copies everything. Existing files are skipped unless --force.",
464
654
  ),
465
655
  );
466
656
 
@@ -556,11 +746,117 @@ export const skillCommand = Command.make("skill", { name: skillName }, ({ name }
556
746
  ),
557
747
  );
558
748
 
749
+ const agent = Flag.Literals("agent", AGENTS).pipe(
750
+ Flag.optional,
751
+ Flag.withDescription(
752
+ "The coding agent to ask this time: claude, codex, or omp, in place of the one setup saved.",
753
+ ),
754
+ );
755
+
756
+ /** `adhere rules create [idea]`: a wizard that writes one rule, with a coding agent's help. */
757
+ const rulesCreateCommand = Command.make(
758
+ "create",
759
+ {
760
+ idea: Argument.String("idea").pipe(
761
+ Argument.optional,
762
+ Argument.withDescription("What the rule should enforce. Asked for when left out."),
763
+ ),
764
+ agent,
765
+ setup: Flag.Boolean("setup").pipe(
766
+ Flag.withDefault(false),
767
+ Flag.withDescription(
768
+ "Choose again which agent to ask and which GitHub repo, as org/repo, holds the organization's shared rules. The first run asks without it.",
769
+ ),
770
+ ),
771
+ },
772
+ (input) => createRule(input.idea, input.agent, input.setup).pipe(Effect.provide(statusLayer)),
773
+ ).pipe(
774
+ Command.withDescription(
775
+ "Write a rule with a coding agent's help: it suggests whether the convention is a native oxlint rule (an id from adhere's catalog; unknown ids are rejected, with its exact option values and the config to print or enable in the shared repo), a mechanical rule (an oxlint JS plugin, approved before it runs and tested on Node 22 or newer; a failed test gets one repair and reapproval), or one adhere must judge. It then suggests whether the rule belongs to this project or the organization's shared rules repo, names it, chooses a RULE.md or, when Jev needs state the file cannot show, a RULE.ts, and says which config context (typeAware, reads) it needs. It finds code in the repo for you to judge, with the lines around it, and drafts the rule from your answers; the draft is checked as adhere validate checks wording. A project rule is written to .adhere/rules/ or .adhere/oxlint/; an organization rule is pushed to the shared repo on a branch for a pull request.",
776
+ ),
777
+ );
778
+
779
+ /** The shared rules repo to refresh instead of this checkout's, as `org/repo`. */
780
+ const sharedRepo = Argument.String("repo").pipe(
781
+ Argument.filterMap(
782
+ (spec) => Option.fromNullishOr(/^[\w.-]+\/[\w.-]+$/.test(spec) ? spec : undefined),
783
+ (spec) => `org/repo, as on GitHub, not ${spec}`,
784
+ ),
785
+ Argument.optional,
786
+ Argument.withDescription(
787
+ "The shared rules repo to refresh, as org/repo, instead of this checkout or the org's standards repo this checkout's origin names.",
788
+ ),
789
+ );
790
+
791
+ /**
792
+ * `adhere rules readme [org/repo]`: refresh the managed index in a shared
793
+ * rules repo's README from the rules and lint config checked out there. In a
794
+ * shared repo it writes that README; otherwise it clones the GitHub repo named
795
+ * here, or the org's standards repo this checkout's origin names, and pushes the refreshed
796
+ * README on a review branch for a pull request.
797
+ */
798
+ const rulesReadmeCommand = Command.make("readme", { repo: sharedRepo }, ({ repo }) =>
799
+ Effect.gen(function* () {
800
+ const path = yield* Path.Path;
801
+ const root = path.resolve();
802
+ const named = Option.isSome(repo) ? repo.value : undefined;
803
+ if (named === undefined && (yield* isSharedRepo(root))) {
804
+ const refreshed = yield* refreshSharedReadme(root);
805
+ yield* Console.log(refreshed.message);
806
+ return;
807
+ }
808
+ const origin = named === undefined ? yield* originUrlAt(root) : undefined;
809
+ // A consumer repo's shared rules live in its org's standards repo, never in the repo itself.
810
+ const target = named ?? (origin === undefined ? undefined : standardsRepoOf(origin));
811
+ if (target === undefined) {
812
+ return yield* ReadmeUnavailable.make({
813
+ message:
814
+ origin === undefined
815
+ ? `${root} is not a shared rules repo, and git names no origin here, so there is no README to refresh. Run this in the shared repo, name it as adhere rules readme org/repo, or add a GitHub origin.`
816
+ : `${root} is not a shared rules repo, and its origin ${origin} is not a GitHub repo. Run this in the shared repo, or name one: adhere rules readme org/repo.`,
817
+ });
818
+ }
819
+ const refreshed = yield* refreshRemoteReadme(target);
820
+ yield* Console.log(
821
+ refreshed.branch === undefined
822
+ ? `${refreshed.message}\nNo change, so nothing was pushed.`
823
+ : [
824
+ refreshed.message,
825
+ `Pushed ${refreshed.branch} to ${refreshed.repo}.`,
826
+ `Open the pull request: https://github.com/${refreshed.repo}/compare/${refreshed.branch}?expand=1`,
827
+ ].join("\n"),
828
+ );
829
+ }).pipe(Effect.provide(statusLayer)),
830
+ ).pipe(
831
+ Command.withDescription(
832
+ "Refresh the managed index in a shared rules repo's README from .adhere/rules/ and oxlint/rules.json: each rule's link and description, each enabled native Oxlint setting, and each custom plugin's enabled rules. In the shared repo it writes that README. Elsewhere it refreshes the GitHub repo named as org/repo, or the org's standards repo this checkout's origin names, from a clone, pushes README.md alone on a review branch, and prints the pull request link; a current README pushes nothing. It preserves the rest of the README and refuses malformed markers.",
833
+ ),
834
+ );
835
+
836
+ export const rulesCommand = Command.make("rules").pipe(
837
+ Command.withDescription("Work on this repo's rules."),
838
+ Command.withSubcommands([rulesCreateCommand, rulesReadmeCommand]),
839
+ );
840
+
841
+ export const postinstallCommand = Command.make("postinstall", {}, () =>
842
+ Effect.gen(function* () {
843
+ const path = yield* Path.Path;
844
+ yield* Console.log(yield* renderInstructions(path.resolve()));
845
+ }),
846
+ ).pipe(
847
+ Command.withDescription(
848
+ "Render instruction files recursively into a managed block in AGENTS.md.",
849
+ ),
850
+ );
851
+
559
852
  /** The whole CLI. Bare `adhere` prints its help. */
560
853
  export const cli = Command.make("adhere").pipe(
561
854
  Command.withDescription("Lint a repository against the rules in .adhere/rules/, judged by Jev."),
562
855
  Command.withExamples([
563
- { command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
856
+ {
857
+ command: "adhere init",
858
+ description: "Scaffold config, tsconfig, and two example rules",
859
+ },
564
860
  {
565
861
  command: "adhere validate",
566
862
  description: "Check the rules' wording, and ask Jev whether any contradict",
@@ -569,7 +865,7 @@ export const cli = Command.make("adhere").pipe(
569
865
  { command: "adhere lint", description: "Audit the working directory" },
570
866
  {
571
867
  command: "adhere install org/repo",
572
- description: "Copy another repo's .adhere/rules/ into this one",
868
+ description: "Copy another repo's instructions and rules into this one",
573
869
  },
574
870
  {
575
871
  command: "adhere lint --preset effect",
@@ -580,10 +876,12 @@ export const cli = Command.make("adhere").pipe(
580
876
  lintCommand,
581
877
  validateCommand,
582
878
  initCommand,
879
+ postinstallCommand,
583
880
  listCommand,
584
881
  installCommand,
585
882
  loginCommand,
586
883
  logoutCommand,
587
884
  skillCommand,
885
+ rulesCommand,
588
886
  ]),
589
887
  );