@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.
- package/README.md +294 -146
- package/bin/adhere.js +19 -4
- package/package.json +34 -12
- package/presets/effect/config/tests-provide-values-directly/RULE.md +12 -1
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +14 -2
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +17 -4
- package/presets/effect/services/dependencies-through-layers/RULE.md +16 -3
- package/presets/effect/services/no-mutable-state/RULE.md +7 -1
- package/skills/adhere/SKILL.md +180 -31
- package/skills/adhere-setup/SKILL.md +142 -16
- package/src/cli.ts +56 -23
- package/src/config.ts +49 -16
- package/src/index.ts +14 -0
- package/src/markdown.ts +9 -5
- package/src/providers/cloudflare.ts +84 -0
- package/src/providers/jev.ts +66 -0
- package/src/providers/openai.ts +89 -0
- package/src/providers/post.ts +53 -0
- package/src/providers/provider.ts +131 -0
- package/src/providers/providers.test.ts +276 -0
- package/src/providers/systemOne.ts +95 -0
- package/src/services/AdhereConfig.ts +2 -4
- package/src/services/Credentials.ts +101 -51
- package/src/services/Jev.http.ts +165 -170
- package/src/services/Jev.provider.test.ts +207 -0
- package/src/services/Jev.reads.test.ts +439 -0
- package/src/services/Jev.ts +205 -113
- package/src/services/NativeLsp.ts +289 -0
- package/src/services/SourceReads.test.ts +1040 -0
- package/src/services/SourceReads.ts +437 -0
- package/src/services/SourceWalker.ts +26 -59
- package/src/workflows/audit.ts +440 -265
- package/src/workflows/format.ts +74 -17
- package/src/workspaces.test.ts +73 -0
- package/src/workspaces.ts +91 -0
- package/presets/effect/services/operations-have-no-requirements/RULE.md +0 -50
- 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
|
-
//
|
|
135
|
-
const workspacePackages =
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/** `
|
|
24
|
-
readonly
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
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(
|
|
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
|
|
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
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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* (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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({
|
|
171
|
+
return Credentials.of({ key, file, save, remove });
|
|
122
172
|
}),
|
|
123
173
|
);
|