@drkmttr/adhere 0.14.0 → 0.17.5

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 (37) hide show
  1. package/README.md +294 -146
  2. package/bin/adhere.js +19 -4
  3. package/package.json +34 -12
  4. package/presets/effect/config/tests-provide-values-directly/RULE.md +12 -1
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +14 -2
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +17 -4
  7. package/presets/effect/services/dependencies-through-layers/RULE.md +16 -3
  8. package/presets/effect/services/no-mutable-state/RULE.md +7 -1
  9. package/skills/adhere/SKILL.md +180 -31
  10. package/skills/adhere-setup/SKILL.md +142 -16
  11. package/src/cli.ts +56 -23
  12. package/src/config.ts +49 -16
  13. package/src/index.ts +14 -0
  14. package/src/markdown.ts +9 -5
  15. package/src/providers/cloudflare.ts +84 -0
  16. package/src/providers/jev.ts +66 -0
  17. package/src/providers/openai.ts +89 -0
  18. package/src/providers/post.ts +53 -0
  19. package/src/providers/provider.ts +131 -0
  20. package/src/providers/providers.test.ts +276 -0
  21. package/src/providers/systemOne.ts +95 -0
  22. package/src/services/AdhereConfig.ts +2 -4
  23. package/src/services/Credentials.ts +101 -51
  24. package/src/services/Jev.http.ts +165 -170
  25. package/src/services/Jev.provider.test.ts +207 -0
  26. package/src/services/Jev.reads.test.ts +439 -0
  27. package/src/services/Jev.ts +205 -113
  28. package/src/services/NativeLsp.ts +289 -0
  29. package/src/services/SourceReads.test.ts +1040 -0
  30. package/src/services/SourceReads.ts +437 -0
  31. package/src/services/SourceWalker.ts +26 -59
  32. package/src/workflows/audit.ts +440 -265
  33. package/src/workflows/format.ts +74 -17
  34. package/src/workspaces.test.ts +73 -0
  35. package/src/workspaces.ts +91 -0
  36. package/presets/effect/services/operations-have-no-requirements/RULE.md +0 -50
  37. package/presets/effect/services/test-layers-are-in-memory/RULE.md +0 -29
@@ -0,0 +1,276 @@
1
+ import type { Rule } from "#config.ts";
2
+ import { cloudflare } from "#providers/cloudflare.ts";
3
+ import { jev } from "#providers/jev.ts";
4
+ import { openai } from "#providers/openai.ts";
5
+ import { ContextOverflow, type Question, RequestFailed } from "#providers/provider.ts";
6
+ import { questionsOf, requestOf } from "#providers/systemOne.ts";
7
+ import { conflictBody, contradictBody, judgeBody, locateBody } from "#services/Jev.ts";
8
+ import { describe, expect, it } from "vite-plus/test";
9
+
10
+ const plain: Rule = {
11
+ description: "Ports must be named.",
12
+ must: "const port = PORT;",
13
+ never: "listen(3000)",
14
+ appliesTo: ["a server's entry point"],
15
+ };
16
+ const lines = ["const port = 3000;", "", "listen(port);"];
17
+ const compared = [
18
+ { id: "a", rule: plain },
19
+ { id: "b", rule: { description: "Ports must be literals.", must: "listen(3000)" } },
20
+ ];
21
+
22
+ /** A fetch that records each request's URL, headers, and body, and answers with `respond`. */
23
+ const recording = (respond: (body: Record<string, unknown>, sent: number) => Response) => {
24
+ const sent: Array<{ url: string; authorization: string | null; body: Record<string, unknown> }> =
25
+ [];
26
+ const fetch = (async (url: unknown, init?: RequestInit) => {
27
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
28
+ sent.push({
29
+ url: String(url),
30
+ authorization: new Headers(init?.headers).get("authorization"),
31
+ body,
32
+ });
33
+ return respond(body, sent.length);
34
+ }) as typeof globalThis.fetch;
35
+ return { sent, fetch };
36
+ };
37
+
38
+ const noul = (id: string): Question => ({ id, type: "noul", question: `Is ${id} so?`, fields: {} });
39
+
40
+ /** What adhere passes `ask` beside the key: a signal no test aborts. */
41
+ const signal = new AbortController().signal;
42
+
43
+ describe("System One", () => {
44
+ it("puts adhere's questions back as adhere built them, for Jev and Clef", () => {
45
+ const bodies = [
46
+ judgeBody("jev-latest", lines, { plain }, ["plain"]),
47
+ locateBody("jev-latest", lines, { plain }),
48
+ conflictBody("jev-latest", compared, [[1], [0]]),
49
+ contradictBody("jev-latest", compared, [0, 1]),
50
+ ];
51
+ for (const body of bodies) {
52
+ const rebuilt = requestOf(body.model, body.state, questionsOf(body.questions));
53
+ expect(JSON.stringify(rebuilt)).toBe(JSON.stringify(body));
54
+ }
55
+ });
56
+ });
57
+
58
+ describe("jev", () => {
59
+ it("sends adhere's request to TypeSafe with the key, and reads each answer as a value", async () => {
60
+ const body = judgeBody("jev-latest", lines, { plain });
61
+ const { sent, fetch } = recording(() =>
62
+ Response.json({
63
+ model: "jev-1.13.0",
64
+ answers: { plain: { noul: 0.91 }, "appliesTo:plain:0": { noul: 0.2 } },
65
+ usage: { input_tokens: 420 },
66
+ }),
67
+ );
68
+ const answered = await jev({ apiKey: "tsk_test", fetch }).ask(
69
+ body.state,
70
+ questionsOf(body.questions),
71
+ { signal },
72
+ );
73
+ expect(sent.map(({ url, authorization }) => [url, authorization])).toEqual([
74
+ ["https://api.typesafe.ai/v1/systemone", "Bearer tsk_test"],
75
+ ]);
76
+ expect(JSON.stringify(sent[0]?.body)).toBe(JSON.stringify(body));
77
+ expect(answered).toEqual({
78
+ answers: { plain: 0.91, "appliesTo:plain:0": 0.2 },
79
+ inputTokens: 420,
80
+ model: "jev-1.13.0",
81
+ });
82
+ });
83
+ });
84
+
85
+ describe("saved keys", () => {
86
+ it("are named by jev and openai built without a key, and asked with the key adhere passes", async () => {
87
+ const toJev = recording(() => Response.json({ answers: {} }));
88
+ const toOpenAI = recording(() => Response.json({ answers: [] }));
89
+ const jevProvider = jev({ fetch: toJev.fetch });
90
+ const openaiProvider = openai({ fetch: toOpenAI.fetch });
91
+ expect([jevProvider.savedKey, openaiProvider.savedKey]).toEqual(["typesafe", "openai"]);
92
+ expect(jev({ apiKey: "tsk_own" }).savedKey).toBeUndefined();
93
+ await jevProvider.ask({}, [noul("a")], { apiKey: "tsk_saved", signal });
94
+ await openaiProvider.ask({}, [noul("a")], { apiKey: "sk-saved", signal });
95
+ expect([...toJev.sent, ...toOpenAI.sent].map(({ authorization }) => authorization)).toEqual([
96
+ "Bearer tsk_saved",
97
+ "Bearer sk-saved",
98
+ ]);
99
+ await expect(jevProvider.ask({}, [noul("a")], { signal })).rejects.toThrow(
100
+ "No TypeSafe AI API key",
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("cloudflare", () => {
106
+ it("asks 64 questions to a request under ids Clef takes, answers a choice of one option unasked, and puts each answer back under its key", async () => {
107
+ const questions: ReadonlyArray<Question> = [
108
+ ...Array.from({ length: 70 }, (_, index) => noul(`linter:rule/${index}`)),
109
+ { id: "start:rule:1", type: "choice", question: "Where?", fields: {}, options: { "3": "x" } },
110
+ ];
111
+ const { sent, fetch } = recording((body) =>
112
+ Response.json({
113
+ result: {
114
+ model: "clef",
115
+ answers: Object.fromEntries(
116
+ Object.keys(body.questions as object).map((id) => [id, { type: "noul", noul: 0.5 }]),
117
+ ),
118
+ usage: { input_tokens: 100 },
119
+ },
120
+ }),
121
+ );
122
+ const answered = await cloudflare({ accountId: "acct", apiToken: "cft", fetch }).ask(
123
+ { code: { "1": "x" } },
124
+ questions,
125
+ { signal },
126
+ );
127
+ expect(sent.map(({ url }) => url)).toEqual([
128
+ "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef",
129
+ "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef",
130
+ ]);
131
+ expect(sent.map(({ body }) => Object.keys(body.questions as object).length)).toEqual([64, 6]);
132
+ expect(Object.keys(sent[1]?.body.questions as object)).toEqual([
133
+ "q0",
134
+ "q1",
135
+ "q2",
136
+ "q3",
137
+ "q4",
138
+ "q5",
139
+ ]);
140
+ expect(answered.answers["linter:rule/69"]).toBe(0.5);
141
+ expect(answered.answers["start:rule:1"]).toBe("3");
142
+ expect(answered.inputTokens).toBe(200);
143
+ });
144
+
145
+ it("skips as over its context a request it read to its limit, or one Workers AI refused as too large", async () => {
146
+ const read = (tokens: number) =>
147
+ recording(() =>
148
+ Response.json({
149
+ result: { answers: { q0: { noul: 0.5 } }, usage: { input_tokens: tokens } },
150
+ }),
151
+ ).fetch;
152
+ const flash = (fetch: typeof globalThis.fetch) =>
153
+ cloudflare({ accountId: "acct", apiToken: "cft", model: "clef-flash", fetch }).ask(
154
+ {},
155
+ [noul("a")],
156
+ { signal },
157
+ );
158
+ expect((await flash(read(23_999))).answers).toEqual({ a: 0.5 });
159
+ await expect(flash(read(24_000))).rejects.toBeInstanceOf(ContextOverflow);
160
+ const tooLarge = recording(() => new Response("{}", { status: 413 })).fetch;
161
+ await expect(flash(tooLarge)).rejects.toBeInstanceOf(ContextOverflow);
162
+ });
163
+ });
164
+
165
+ describe("openai", () => {
166
+ it("asks each question as a decision question, its parts in JSON, and leaves a refusal unanswered", async () => {
167
+ const questions: ReadonlyArray<Question> = [
168
+ {
169
+ id: "plain",
170
+ type: "noul",
171
+ question: "Does `code` diverge from `must`?",
172
+ fields: { rule: "Ports must be named.", must: "const port = PORT;" },
173
+ yes: "it diverges",
174
+ no: "it follows",
175
+ },
176
+ {
177
+ id: "plain:section",
178
+ type: "choice",
179
+ question: "Which?",
180
+ fields: {},
181
+ options: { "1": null, "2": "the second" },
182
+ },
183
+ {
184
+ id: "start:plain:1",
185
+ type: "choice",
186
+ question: "Where?",
187
+ fields: {},
188
+ options: { "7": "x" },
189
+ },
190
+ noul("refused"),
191
+ ];
192
+ const { sent, fetch } = recording(() =>
193
+ Response.json({
194
+ model: "gpt-6-luna",
195
+ answers: [
196
+ { type: "predicate", name: "plain", probability: 0.9 },
197
+ {
198
+ type: "choice",
199
+ name: "plain:section",
200
+ choice: "2",
201
+ probabilities: [],
202
+ confidence: 0.8,
203
+ },
204
+ { type: "refusal", name: "refused" },
205
+ ],
206
+ usage: { input_tokens: 300 },
207
+ }),
208
+ );
209
+ const answered = await openai({ apiKey: "sk-test", fetch }).ask(
210
+ { code: { "1": "x" } },
211
+ questions,
212
+ { signal },
213
+ );
214
+ expect(sent[0]?.body).toEqual({
215
+ model: "gpt-6-luna",
216
+ input: JSON.stringify({ code: { "1": "x" } }),
217
+ questions: [
218
+ {
219
+ type: "predicate",
220
+ name: "plain",
221
+ instructions: JSON.stringify({
222
+ instructions: {
223
+ question: "Does `code` diverge from `must`?",
224
+ rule: "Ports must be named.",
225
+ must: "const port = PORT;",
226
+ },
227
+ criteria: { true: "it diverges", false: "it follows" },
228
+ }),
229
+ },
230
+ {
231
+ type: "choice",
232
+ name: "plain:section",
233
+ instructions: "Which?",
234
+ choices: [{ value: "1" }, { value: "2", description: "the second" }],
235
+ },
236
+ { type: "predicate", name: "refused", instructions: "Is refused so?" },
237
+ ],
238
+ });
239
+ expect(answered).toEqual({
240
+ answers: { "start:plain:1": "7", plain: 0.9, "plain:section": "2" },
241
+ inputTokens: 300,
242
+ model: "gpt-6-luna",
243
+ });
244
+ });
245
+ });
246
+
247
+ describe("a failed request", () => {
248
+ it("is thrown once, as a RequestFailed with its status and Ray ID, or status 0 when nothing answered", async () => {
249
+ const busy = recording(
250
+ () => new Response("busy\n", { status: 503, headers: { "cf-ray": "8c1f-LAX" } }),
251
+ );
252
+ const failed = await jev({ apiKey: "tsk_test", fetch: busy.fetch })
253
+ .ask({}, [noul("a")], { signal })
254
+ .catch((cause: unknown) => cause);
255
+ expect(failed).toBeInstanceOf(RequestFailed);
256
+ expect(failed).toMatchObject({
257
+ status: 503,
258
+ message: "Jev answered HTTP 503, Ray ID 8c1f-LAX: busy",
259
+ });
260
+ expect(busy.sent).toHaveLength(1);
261
+
262
+ const down = (async () => {
263
+ throw new TypeError("fetch failed");
264
+ }) as unknown as typeof globalThis.fetch;
265
+ await expect(
266
+ openai({ apiKey: "sk-test", fetch: down }).ask({}, [noul("a")], { signal }),
267
+ ).rejects.toMatchObject({ status: 0, message: "OpenAI did not answer: fetch failed" });
268
+ });
269
+
270
+ it("is an answer that does not decode, naming who sent it", async () => {
271
+ const { fetch } = recording(() => Response.json({ answers: "none" }));
272
+ await expect(
273
+ openai({ apiKey: "sk-test", fetch }).ask({}, [noul("a")], { signal }),
274
+ ).rejects.toThrow("OpenAI's answer did not decode");
275
+ });
276
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * System One, the format Jev takes and Clef follows, and adhere's questions
3
+ * in it. adhere builds its questions this way, as its cache and Jev have
4
+ * always read them; a provider gets them split into their parts by
5
+ * `questionsOf`, and Jev's and Clef's put them back with `requestOf`.
6
+ */
7
+ import type { Answered, Question, State } from "#providers/provider.ts";
8
+ import { Schema } from "effect";
9
+
10
+ /** A question's instructions: the question alone, or beside the fields it names. */
11
+ type Instructions = string | (Readonly<Record<string, unknown>> & { readonly question: string });
12
+
13
+ /** A question as System One takes it: a `noul` with what yes and no mean, or a `choice` among its criteria's keys. */
14
+ export type SystemOneQuestion =
15
+ | {
16
+ readonly type: "noul";
17
+ readonly instructions: Instructions;
18
+ readonly criteria?: { readonly true: string; readonly false: string };
19
+ }
20
+ | {
21
+ readonly type: "choice";
22
+ readonly instructions: Instructions;
23
+ readonly criteria: Readonly<Record<string, string | null>>;
24
+ };
25
+
26
+ /** A request as System One takes it: questions by id about `state`, its questions typed as `Q`. */
27
+ export interface SystemOneRequest<Q = SystemOneQuestion> {
28
+ readonly model: string;
29
+ readonly state: State;
30
+ readonly questions: Readonly<Record<string, Q>>;
31
+ }
32
+
33
+ /**
34
+ * What System One answers: by id, a `noul`'s probability or a `choice`'s
35
+ * key, with the model that answered and the tokens it read.
36
+ */
37
+ export const SystemOneAnswered = Schema.Struct({
38
+ answers: Schema.Record(
39
+ Schema.String,
40
+ Schema.Struct({
41
+ noul: Schema.optionalKey(Schema.Finite),
42
+ choice: Schema.optionalKey(Schema.String),
43
+ }),
44
+ ),
45
+ model: Schema.optionalKey(Schema.String),
46
+ usage: Schema.optionalKey(Schema.Struct({ input_tokens: Schema.Finite })),
47
+ });
48
+
49
+ /** Each question of a request split into what it asks, the fields it names, and what its answers mean. */
50
+ export const questionsOf = (
51
+ questions: Readonly<Record<string, SystemOneQuestion>>,
52
+ ): ReadonlyArray<Question> =>
53
+ Object.entries(questions).map(([id, asked]) => {
54
+ const { question, ...fields } =
55
+ typeof asked.instructions === "string"
56
+ ? { question: asked.instructions }
57
+ : asked.instructions;
58
+ if (asked.type === "choice")
59
+ return { id, type: "choice", question, fields, options: asked.criteria };
60
+ return asked.criteria === undefined
61
+ ? { id, type: "noul", question, fields }
62
+ : { id, type: "noul", question, fields, yes: asked.criteria.true, no: asked.criteria.false };
63
+ });
64
+
65
+ /** A question as System One asks it, as adhere built it: a question with no fields is its instructions alone. */
66
+ export const systemOneQuestionOf = (asked: Question): SystemOneQuestion => {
67
+ const instructions =
68
+ Object.keys(asked.fields).length === 0
69
+ ? asked.question
70
+ : { question: asked.question, ...asked.fields };
71
+ if (asked.type === "choice") return { type: "choice", instructions, criteria: asked.options };
72
+ return asked.yes === undefined || asked.no === undefined
73
+ ? { type: "noul", instructions }
74
+ : { type: "noul", instructions, criteria: { true: asked.yes, false: asked.no } };
75
+ };
76
+
77
+ /** A request as System One takes it, each question by its id. */
78
+ export const requestOf = (
79
+ model: string,
80
+ state: State,
81
+ questions: ReadonlyArray<Question>,
82
+ ): SystemOneRequest => ({
83
+ model,
84
+ state,
85
+ questions: Object.fromEntries(questions.map((asked) => [asked.id, systemOneQuestionOf(asked)])),
86
+ });
87
+
88
+ /** System One's answers as a provider gives them: each a probability or a key. One with neither is left out. */
89
+ export const answersOf = (answers: typeof SystemOneAnswered.Type.answers): Answered["answers"] =>
90
+ Object.fromEntries(
91
+ Object.entries(answers).flatMap(
92
+ ([id, { noul, choice }]): ReadonlyArray<readonly [string, number | string]> =>
93
+ noul !== undefined ? [[id, noul]] : choice !== undefined ? [[id, choice]] : [],
94
+ ),
95
+ );
@@ -131,10 +131,8 @@ export const AdhereConfigLive = (flags: Flags) =>
131
131
  }
132
132
  }
133
133
  const scopedRules = withOverrides(entries, overrides);
134
- // The manifests are read on every run, so a changed workspace is seen, and only when a rule reads them.
135
- const workspacePackages = scopedRules.some(
136
- (entry) => entry.rule.reads?.includes("workspacePackages") === true,
137
- )
134
+ // Read on every run so workspace edits are seen, but only when the config enables this context.
135
+ const workspacePackages = resolved.reads.includes("workspacePackages")
138
136
  ? yield* workspacePackagesOf(cwd)
139
137
  : undefined;
140
138
  return AdhereConfig.of({
@@ -1,3 +1,4 @@
1
+ import type { SavedKey } from "#providers/provider.ts";
1
2
  import { Config, Context, Effect, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect";
2
3
 
3
4
  /** No key to send, or the saved key cannot be read or written. */
@@ -6,37 +7,67 @@ export class CredentialsUnavailable extends Schema.TaggedError<CredentialsUnavai
6
7
  { message: Schema.String },
7
8
  ) {}
8
9
 
9
- /** A TypeSafe AI API key is one word: a space or a second line is a bad paste. */
10
+ /** An API key is one word: a space or a second line is a bad paste. */
10
11
  const ApiKey = Schema.String.pipe(Schema.check(Schema.isPattern(/^\S+$/)));
11
12
 
12
- /** What `adhere login` saves. */
13
+ /** What `adhere login` saves: TypeSafe AI's key, and with `--openai`, OpenAI's. */
13
14
  class SavedCredentials extends Schema.Class<SavedCredentials>("SavedCredentials")({
14
- apiKey: Schema.RedactedFromValue(ApiKey),
15
+ apiKey: Schema.optionalKey(Schema.RedactedFromValue(ApiKey)),
16
+ openaiApiKey: Schema.optionalKey(Schema.RedactedFromValue(ApiKey)),
15
17
  }) {}
16
18
 
17
19
  const SavedCredentialsJson = Schema.fromJsonString(SavedCredentials);
18
20
 
19
- /** The TypeSafe AI API key: where a request gets it, and where `adhere login` keeps it. */
21
+ /** The saved keys, by the name a provider asks for them by. */
22
+ type Saved = Partial<Record<SavedKey, Redacted.Redacted<string>>>;
23
+
24
+ /**
25
+ * Each key: the variable that wins over the saved one, how `adhere login`
26
+ * asks for it and names it, and what a run says when there is none, or when
27
+ * the file holds none.
28
+ */
29
+ export const KEYS = {
30
+ typesafe: {
31
+ variable: "TYPESAFE_API_KEY",
32
+ label: "TypeSafe AI API key",
33
+ said: "API key",
34
+ missing: "No TypeSafe AI API key. Run `adhere login` to save one, or set TYPESAFE_API_KEY.",
35
+ absent: (file: string) => `${file} holds no API key. Run \`adhere login\` to save one again.`,
36
+ },
37
+ openai: {
38
+ variable: "OPENAI_API_KEY",
39
+ label: "OpenAI API key",
40
+ said: "OpenAI API key",
41
+ missing: "No OpenAI API key. Run `adhere login --openai` to save one, or set OPENAI_API_KEY.",
42
+ absent: (file: string) =>
43
+ `${file} holds no OpenAI API key. Run \`adhere login --openai\` to save one.`,
44
+ },
45
+ } as const satisfies Record<SavedKey, unknown>;
46
+
47
+ /** The API keys providers ask with: where a request gets one, and where `adhere login` keeps them. */
20
48
  export class Credentials extends Context.Service<
21
49
  Credentials,
22
50
  {
23
- /** `TYPESAFE_API_KEY` when it is set, otherwise the key `adhere login` saved. */
24
- readonly apiKey: Effect.Effect<Redacted.Redacted<string>, CredentialsUnavailable>;
25
- /** Where the saved key lives, whether or not there is one. */
51
+ /** `name`'s key: its variable, such as `TYPESAFE_API_KEY`, when set, otherwise the one `adhere login` saved. */
52
+ readonly key: (
53
+ name: SavedKey,
54
+ ) => Effect.Effect<Redacted.Redacted<string>, CredentialsUnavailable>;
55
+ /** Where the saved keys live, whether or not there are any. */
26
56
  readonly file: Effect.Effect<string, CredentialsUnavailable>;
27
- /** Saves a key for later runs, readable only by the user. */
57
+ /** Saves `name`'s key for later runs, beside the other, readable only by the user. */
28
58
  readonly save: (
59
+ name: SavedKey,
29
60
  apiKey: Redacted.Redacted<string>,
30
61
  ) => Effect.Effect<void, CredentialsUnavailable>;
31
- /** Deletes the saved key. False when there was none. */
32
- readonly remove: Effect.Effect<boolean, CredentialsUnavailable>;
62
+ /** Deletes `name`'s saved key, and the file with no key left in it. False when there was none. */
63
+ readonly remove: (name: SavedKey) => Effect.Effect<boolean, CredentialsUnavailable>;
33
64
  }
34
65
  >()("@drkmttr/adhere/services/Credentials") {}
35
66
 
36
67
  const refused = (message: string) => CredentialsUnavailable.make({ message });
37
68
 
38
69
  /**
39
- * The key lives in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
70
+ * The keys live in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
40
71
  * `~/.config` without it, on every platform. The file is written whole to a
41
72
  * temporary file created readable only by the user, then renamed over the
42
73
  * old one, so no reader sees half a key or a key with looser permissions.
@@ -59,65 +90,84 @@ export const CredentialsLive = Layer.effect(Credentials)(
59
90
  ),
60
91
  );
61
92
 
62
- const exists = (target: string) =>
63
- fs
64
- .exists(target)
65
- .pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
66
-
67
- const readSaved = Effect.fn("Credentials.readSaved")(function* (target: string) {
68
- const text = yield* fs
69
- .readFileString(target)
70
- .pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
71
- const saved = yield* Schema.decodeUnknownEffect(SavedCredentialsJson)(text).pipe(
93
+ /**
94
+ * The keys the file holds, or nothing when there is no file. A file that
95
+ * does not read as keys refuses, so nothing writes over what it holds.
96
+ */
97
+ const savedIn = Effect.fn("Credentials.savedIn")(function* (target: string) {
98
+ const text = yield* Effect.gen(function* () {
99
+ return (yield* fs.exists(target)) ? yield* fs.readFileString(target) : undefined;
100
+ }).pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
101
+ if (text === undefined) return undefined;
102
+ const { apiKey, openaiApiKey } = yield* Schema.decodeUnknownEffect(SavedCredentialsJson)(
103
+ text,
104
+ ).pipe(
72
105
  Effect.mapError(() =>
73
- refused(`${target} holds no API key. Run \`adhere login\` to save one again.`),
106
+ refused(
107
+ `${target} does not hold keys as \`adhere login\` saves them. Fix it, or delete it and run \`adhere login\` again.`,
108
+ ),
74
109
  ),
75
110
  );
76
- return saved.apiKey;
111
+ return {
112
+ ...(apiKey === undefined ? {} : { typesafe: apiKey }),
113
+ ...(openaiApiKey === undefined ? {} : { openai: openaiApiKey }),
114
+ } satisfies Saved;
115
+ });
116
+
117
+ /** Writes the keys whole, as the layer's comment says; the file goes when none is left. */
118
+ const write = Effect.fn("Credentials.write")(function* (target: string, saved: Saved) {
119
+ const temporary = `${target}.tmp`;
120
+ const json = yield* Schema.encodeEffect(SavedCredentialsJson)(
121
+ new SavedCredentials({
122
+ ...(saved.typesafe === undefined ? {} : { apiKey: saved.typesafe }),
123
+ ...(saved.openai === undefined ? {} : { openaiApiKey: saved.openai }),
124
+ }),
125
+ ).pipe(Effect.orDie);
126
+ yield* Effect.gen(function* () {
127
+ if (saved.typesafe === undefined && saved.openai === undefined) {
128
+ return yield* fs.remove(target, { force: true });
129
+ }
130
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true, mode: 0o700 });
131
+ // A mode applies only when a file is created: never reuse a leftover.
132
+ yield* fs.remove(temporary, { force: true });
133
+ yield* fs.writeFileString(temporary, `${json}\n`, { mode: 0o600 });
134
+ yield* fs.rename(temporary, target);
135
+ }).pipe(Effect.mapError((problem) => refused(`Cannot save ${target}: ${problem.message}`)));
77
136
  });
78
137
 
79
138
  // Read when a request needs the key, not before: a cached run needs none.
80
- const apiKey = Effect.fn("Credentials.apiKey")(function* () {
81
- const fromEnvironment = yield* Config.option(Config.Redacted("TYPESAFE_API_KEY")).pipe(
82
- Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
139
+ const key = Effect.fn("Credentials.key")(function* (name: SavedKey) {
140
+ const { variable, missing, absent } = KEYS[name];
141
+ const fromEnvironment = yield* Config.option(Config.Redacted(variable)).pipe(
142
+ Effect.mapError((problem) => refused(`Cannot read ${variable}: ${problem.message}`)),
83
143
  );
84
144
  if (Option.isSome(fromEnvironment)) return fromEnvironment.value;
85
145
  const target = yield* file;
86
- if (!(yield* exists(target))) {
87
- return yield* refused(
88
- "No TypeSafe AI API key. Run `adhere login` to save one, or set TYPESAFE_API_KEY.",
89
- );
90
- }
91
- return yield* readSaved(target);
146
+ const saved = yield* savedIn(target);
147
+ if (saved === undefined) return yield* refused(missing);
148
+ return saved[name] ?? (yield* refused(absent(target)));
92
149
  });
93
150
 
94
- const save = Effect.fn("Credentials.save")(function* (key: Redacted.Redacted<string>) {
95
- const saved = yield* Schema.decodeUnknownEffect(SavedCredentials)({
96
- apiKey: Redacted.value(key),
97
- }).pipe(
151
+ const save = Effect.fn("Credentials.save")(function* (
152
+ name: SavedKey,
153
+ apiKey: Redacted.Redacted<string>,
154
+ ) {
155
+ yield* Schema.decodeUnknownEffect(ApiKey)(Redacted.value(apiKey)).pipe(
98
156
  Effect.mapError(() => refused("An API key is one word, with no spaces or line breaks.")),
99
157
  );
100
- const json = yield* Schema.encodeEffect(SavedCredentialsJson)(saved).pipe(Effect.orDie);
101
158
  const target = yield* file;
102
- const temporary = `${target}.tmp`;
103
- yield* Effect.gen(function* () {
104
- yield* fs.makeDirectory(path.dirname(target), { recursive: true, mode: 0o700 });
105
- // A mode applies only when a file is created: never reuse a leftover.
106
- yield* fs.remove(temporary, { force: true });
107
- yield* fs.writeFileString(temporary, `${json}\n`, { mode: 0o600 });
108
- yield* fs.rename(temporary, target);
109
- }).pipe(Effect.mapError((problem) => refused(`Cannot save ${target}: ${problem.message}`)));
159
+ yield* write(target, { ...(yield* savedIn(target)), [name]: apiKey });
110
160
  });
111
161
 
112
- const remove = Effect.fn("Credentials.remove")(function* () {
162
+ const remove = Effect.fn("Credentials.remove")(function* (name: SavedKey) {
113
163
  const target = yield* file;
114
- if (!(yield* exists(target))) return false;
115
- yield* fs
116
- .remove(target)
117
- .pipe(Effect.mapError((problem) => refused(`Cannot delete ${target}: ${problem.message}`)));
164
+ const rest: Saved = { ...(yield* savedIn(target)) };
165
+ if (rest[name] === undefined) return false;
166
+ delete rest[name];
167
+ yield* write(target, rest);
118
168
  return true;
119
169
  });
120
170
 
121
- return Credentials.of({ apiKey: apiKey(), file, save, remove: remove() });
171
+ return Credentials.of({ key, file, save, remove });
122
172
  }),
123
173
  );