@cliftonc/finius 0.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/branding.js +28 -0
  4. package/dist/cli/backfill.js +122 -0
  5. package/dist/cli/claude-settings.js +54 -0
  6. package/dist/cli/codex-config.js +60 -0
  7. package/dist/cli/codex.js +97 -0
  8. package/dist/cli/config.js +41 -0
  9. package/dist/cli/doctor.js +159 -0
  10. package/dist/cli/hook.js +70 -0
  11. package/dist/cli/identity.js +163 -0
  12. package/dist/cli/import.js +61 -0
  13. package/dist/cli/index.js +70 -0
  14. package/dist/cli/install.js +23 -0
  15. package/dist/cli/password.js +14 -0
  16. package/dist/cli/serve.js +63 -0
  17. package/dist/cli/setup.js +314 -0
  18. package/dist/cli/ui.js +15 -0
  19. package/dist/client/assets/TranscriptView-CBf7-4Bo.css +1 -0
  20. package/dist/client/assets/TranscriptView-CLCPX5bI.js +194 -0
  21. package/dist/client/assets/TranscriptView-D056GDHO.js +194 -0
  22. package/dist/client/assets/TranscriptView-MIgsAwMN.js +194 -0
  23. package/dist/client/assets/index-6OIY_8fO.css +1 -0
  24. package/dist/client/assets/index-9aN8py7_.js +1 -0
  25. package/dist/client/assets/index-B-sjMmTS.js +1636 -0
  26. package/dist/client/assets/index-B4HbP3X6.js +1 -0
  27. package/dist/client/assets/index-B9wgN1BV.js +1636 -0
  28. package/dist/client/assets/index-BHlFz1Th.js +1652 -0
  29. package/dist/client/assets/index-BJyvYca7.js +1636 -0
  30. package/dist/client/assets/index-BKBTeJLz.js +1 -0
  31. package/dist/client/assets/index-BN6CbirS.js +1444 -0
  32. package/dist/client/assets/index-BW4_7xR6.js +1460 -0
  33. package/dist/client/assets/index-BaLElA30.js +1 -0
  34. package/dist/client/assets/index-BaQ02V5d.css +1 -0
  35. package/dist/client/assets/index-Bh0dgUU-.js +1636 -0
  36. package/dist/client/assets/index-Bie86XRc.js +1 -0
  37. package/dist/client/assets/index-Bijt5al-.css +1 -0
  38. package/dist/client/assets/index-BikJP2HS.js +1636 -0
  39. package/dist/client/assets/index-BkwrvP-J.js +1 -0
  40. package/dist/client/assets/index-BwVuUJSv.js +1 -0
  41. package/dist/client/assets/index-BweXI4-D.css +1 -0
  42. package/dist/client/assets/index-BwqdHcDE.js +1 -0
  43. package/dist/client/assets/index-C-Z0w-tQ.js +1652 -0
  44. package/dist/client/assets/index-C2RmKzem.js +1636 -0
  45. package/dist/client/assets/index-CHz-iKIQ.js +1 -0
  46. package/dist/client/assets/index-CIGl5oW_.js +1646 -0
  47. package/dist/client/assets/index-CVYmd4Bm.js +1465 -0
  48. package/dist/client/assets/index-Ca9UVGK1.js +1 -0
  49. package/dist/client/assets/index-CeWDkmJN.js +1 -0
  50. package/dist/client/assets/index-CpsNq0zm.css +1 -0
  51. package/dist/client/assets/index-CrUS6abD.css +1 -0
  52. package/dist/client/assets/index-Ctq8vj2Z.js +1 -0
  53. package/dist/client/assets/index-D1ktp0pp.js +1 -0
  54. package/dist/client/assets/index-D3BoYpFi.css +1 -0
  55. package/dist/client/assets/index-D59GxlrT.js +1636 -0
  56. package/dist/client/assets/index-D5Wkww8x.css +1 -0
  57. package/dist/client/assets/index-DC94jMGe.js +1 -0
  58. package/dist/client/assets/index-DFcIBkv1.js +1652 -0
  59. package/dist/client/assets/index-DmKj5Jqc.css +1 -0
  60. package/dist/client/assets/index-Dx52i05H.js +1465 -0
  61. package/dist/client/assets/index-L3GnPzmU.css +1 -0
  62. package/dist/client/assets/index-OZADsKet.js +1652 -0
  63. package/dist/client/assets/index-Qt124kj1.js +1652 -0
  64. package/dist/client/assets/index-nHzwQ3EM.js +1 -0
  65. package/dist/client/assets/index-s9Mg6LTO.js +1 -0
  66. package/dist/client/assets/index-ye8oxz8P.js +1 -0
  67. package/dist/client/assets/index-yqJS7tUY.css +1 -0
  68. package/dist/client/favicon.svg +35 -0
  69. package/dist/client/finius-dashboard.png +0 -0
  70. package/dist/client/index.html +38 -0
  71. package/dist/server/app.js +285 -0
  72. package/dist/server/claude.js +124 -0
  73. package/dist/server/codex.js +94 -0
  74. package/dist/server/events.js +12 -0
  75. package/dist/server/index.js +119 -0
  76. package/dist/server/otel.js +231 -0
  77. package/dist/server/pricing-backfill.js +41 -0
  78. package/dist/server/pricing.js +138 -0
  79. package/dist/server/queue.js +35 -0
  80. package/dist/server/storage/blob.js +17 -0
  81. package/dist/server/storage/query-helpers.js +104 -0
  82. package/dist/server/storage/sqlite.js +1167 -0
  83. package/dist/server/transcripts.js +46 -0
  84. package/dist/server/types.js +1 -0
  85. package/dist/shared/api-types.js +1 -0
  86. package/package.json +72 -0
@@ -0,0 +1,314 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir, hostname } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { confirm, intro, log, note, outro, password as passwordPrompt, spinner, text } from "@clack/prompts";
6
+ import { TELEMETRY_HOOK_EVENTS, withFiniusHook, withTelemetryEnv } from "./claude-settings.js";
7
+ import { backfill, findClaudeTranscripts } from "./backfill.js";
8
+ import { applyCodexConfig, CODEX_CONFIG_PATH, CODEX_SOURCE, findCodexRollouts, isCodexInstalled } from "./codex.js";
9
+ import { CONFIG_PATH, DEFAULT_SERVER_URL, loadConfig, normalizeUrl, resolveAuthToken, saveConfig } from "./config.js";
10
+ import { readClaudeAccount, readCodexAccount, readGithub, readGitIdentity } from "./identity.js";
11
+ import { installGlobally, isFiniusOnPath } from "./install.js";
12
+ import { generateAuthToken, generatePassword } from "./password.js";
13
+ import { ask, banner, pc } from "./ui.js";
14
+ const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
15
+ export async function runSetup(args = []) {
16
+ banner("setup");
17
+ intro(pc.bgCyan(pc.black(" Configure Finius ")));
18
+ const current = loadConfig();
19
+ const defaultUrl = current?.serverUrl ?? DEFAULT_SERVER_URL;
20
+ // A server URL can be passed directly (e.g. `finius setup http://localhost:8787`, the form the
21
+ // dashboard's setup modal hands you) — when valid we skip the prompt and use it.
22
+ const argUrl = args.map((a) => normalizeUrl(a)).find((a) => Boolean(a));
23
+ const serverUrl = argUrl
24
+ ? (log.info(`Using server URL ${pc.cyan(argUrl)}`), argUrl)
25
+ : normalizeUrl(ask(await text({
26
+ message: "Finius server URL",
27
+ placeholder: defaultUrl,
28
+ defaultValue: defaultUrl,
29
+ validate: (value) => (value && !normalizeUrl(value) ? "Enter a valid URL" : undefined)
30
+ }))) || defaultUrl;
31
+ const health = await checkServer(serverUrl);
32
+ // Resolve authentication (Secure Mode). May generate a master password (owner of a new server) or
33
+ // exchange one for a client token (joining an existing secure server). No-op for open servers.
34
+ const auth = await configureAuth(serverUrl, health, {
35
+ authPassword: current?.authPassword,
36
+ authToken: current?.authToken
37
+ });
38
+ // Capture a user identity so uploaded transcripts are attributed to a person (and line up with
39
+ // live OTEL metrics). Detected from the local Claude/Codex/GitHub/git state, confirmed by the user.
40
+ const identity = await configureIdentity(current);
41
+ saveConfig({ serverUrl, authPassword: auth.authPassword, authToken: auth.authToken, identity });
42
+ log.success(`Saved config ${pc.dim(CONFIG_PATH)}`);
43
+ // Install globally so the bare `finius` command works everywhere — including the hook, which then
44
+ // needs only `finius hook` rather than an absolute path tied to this npx cache.
45
+ let onPath = isFiniusOnPath();
46
+ if (!onPath) {
47
+ const wantGlobal = ask(await confirm({ message: "Install finius globally so the `finius` command works everywhere?" }));
48
+ if (wantGlobal) {
49
+ const s = spinner();
50
+ s.start("Running `npm install -g @cliftonc/finius`");
51
+ onPath = installGlobally();
52
+ s.stop(onPath
53
+ ? "Installed — `finius` is now on your PATH"
54
+ : pc.yellow("Global install didn't complete; the hook will use an absolute path instead."));
55
+ }
56
+ }
57
+ // Only offer to configure agents that are actually installed.
58
+ const credential = resolveAuthToken({ serverUrl, ...auth });
59
+ const haveClaude = isClaudeInstalled();
60
+ const haveCodex = isCodexInstalled();
61
+ if (haveClaude)
62
+ await configureClaude(serverUrl, credential, onPath, health.reachable);
63
+ if (haveCodex)
64
+ await configureCodex(serverUrl, credential, onPath, health.reachable);
65
+ if (!haveClaude && !haveCodex) {
66
+ log.warn("No Claude Code or Codex install detected — skipping agent configuration.\n" +
67
+ "Re-run `finius setup` after installing one, or import transcripts manually.");
68
+ }
69
+ outro(`${pc.green("Done.")} Next: run ${pc.cyan("finius serve")} to start the server + dashboard.`);
70
+ return 0;
71
+ }
72
+ async function checkServer(serverUrl) {
73
+ const s = spinner();
74
+ s.start(`Checking server at ${serverUrl}`);
75
+ try {
76
+ const res = await fetch(`${serverUrl}/api/health`, { signal: AbortSignal.timeout(2_000) });
77
+ if (res.ok) {
78
+ const body = (await res.json().catch(() => ({})));
79
+ s.stop(`Server reachable at ${pc.cyan(serverUrl)}`);
80
+ return { reachable: true, secure: !!body.secure };
81
+ }
82
+ s.stop(pc.yellow(`Server responded with ${res.status} — start it with \`finius serve\``));
83
+ }
84
+ catch {
85
+ s.stop(pc.yellow("Server not reachable yet — start it later with `finius serve`"));
86
+ }
87
+ return { reachable: false, secure: false };
88
+ }
89
+ // Resolve this machine's auth credential. Two paths:
90
+ // • Joining an existing Secure Mode server → prompt for the password and exchange it for a per-client
91
+ // session token (persisted as authToken).
92
+ // • An open/new server → offer to enable Secure Mode by generating a master password plus an owner
93
+ // token. The password starts the server; the token is seeded into the DB and used by clients.
94
+ // Existing credentials are kept on re-run (doctor verifies them).
95
+ async function configureAuth(serverUrl, health, current) {
96
+ if (health.secure) {
97
+ if (current.authToken) {
98
+ log.info("Server requires authentication — keeping the client token already in your config.");
99
+ return current;
100
+ }
101
+ if (current.authPassword) {
102
+ const token = await login(serverUrl, current.authPassword);
103
+ if (token) {
104
+ log.success("Authenticated with the saved password — saved a client token for this machine.");
105
+ return { ...current, authToken: token };
106
+ }
107
+ log.warn("Saved password was rejected by the server login endpoint.");
108
+ }
109
+ log.warn("This Finius server requires authentication.");
110
+ for (;;) {
111
+ const entered = ask(await passwordPrompt({ message: "Server password" }));
112
+ const trimmed = entered.trim();
113
+ if (!trimmed) {
114
+ if (ask(await confirm({ message: "No password entered — skip auth setup for now?", initialValue: false }))) {
115
+ return current;
116
+ }
117
+ continue;
118
+ }
119
+ const token = await login(serverUrl, trimmed);
120
+ if (token) {
121
+ log.success("Authenticated — saved a client token for this machine.");
122
+ return { authToken: token };
123
+ }
124
+ log.error("That password was rejected — try again.");
125
+ }
126
+ }
127
+ if (current.authPassword) {
128
+ if (current.authToken) {
129
+ log.info("Auth is enabled (this machine owns the password). Keeping it.");
130
+ return current;
131
+ }
132
+ log.info("Auth is enabled (this machine owns the password). Creating an owner token.");
133
+ return { ...current, authToken: generateAuthToken() };
134
+ }
135
+ const wantAuth = ask(await confirm({
136
+ message: "Require authentication to view/ingest?",
137
+ // Off by default — fine for localhost or a trusted private network.
138
+ initialValue: false
139
+ }));
140
+ if (!wantAuth)
141
+ return {};
142
+ const pw = generatePassword();
143
+ const token = generateAuthToken();
144
+ note(`${pc.bold(pc.cyan(pw))}\n\n${pc.dim("Save it now — other machines need it to connect, and\nyou'll use it to log in to the dashboard.")}`, "Secure Mode enabled — your Finius password");
145
+ return { authPassword: pw, authToken: token };
146
+ }
147
+ // Detect a user identity from the local agent state and confirm it with the user, so the hook/backfill
148
+ // can attribute uploaded transcripts to a person. Preference for the default offered: the Claude
149
+ // account email (it matches live OTEL), falling back to GitHub, then git. Codex is captured as-is (its
150
+ // real ChatGPT account). The GitHub login/display name are captured once here (never in the hot hook).
151
+ async function configureIdentity(current) {
152
+ const claude = readClaudeAccount();
153
+ const codex = readCodexAccount();
154
+ const github = readGithub();
155
+ const git = readGitIdentity();
156
+ const detected = [];
157
+ if (claude?.email)
158
+ detected.push(`${pc.dim("Claude account")} ${claude.email}`);
159
+ if (codex?.email)
160
+ detected.push(`${pc.dim("Codex account")} ${codex.email} ${pc.dim("(used as-is for Codex)")}`);
161
+ if (github?.githubLogin) {
162
+ detected.push(`${pc.dim("GitHub")} @${github.githubLogin}${github.email ? pc.dim(` (${github.email})`) : ""}`);
163
+ }
164
+ if (!claude?.email && git?.email)
165
+ detected.push(`${pc.dim("Git identity")} ${git.email} ${pc.dim("(fallback)")}`);
166
+ if (detected.length)
167
+ note(detected.join("\n"), "Detected accounts");
168
+ const stored = current?.identity;
169
+ const defaultEmail = stored?.claude?.email ?? claude?.email ?? github?.email ?? git?.email ?? "";
170
+ const answer = ask(await text({
171
+ message: "Your email for attribution",
172
+ placeholder: defaultEmail || "none",
173
+ defaultValue: defaultEmail,
174
+ initialValue: defaultEmail
175
+ })).trim();
176
+ const email = answer || defaultEmail || undefined;
177
+ // Only keep the detected account ids when the confirmed email still matches the detected account —
178
+ // a hand-typed override belongs to a different identity, so we drop ids that aren't its own.
179
+ const claudeSlot = email && claude?.email === email
180
+ ? { email, accountId: claude.accountId, userId: claude.userId }
181
+ : email
182
+ ? { email, accountId: stored?.claude?.accountId, userId: stored?.claude?.userId }
183
+ : stored?.claude;
184
+ const codexSlot = codex?.email || codex?.accountId
185
+ ? { email: codex.email, accountId: codex.accountId }
186
+ : stored?.codex;
187
+ const githubLogin = github?.githubLogin ?? stored?.githubLogin;
188
+ const displayName = github?.displayName ?? claude?.displayName ?? git?.displayName ?? stored?.displayName;
189
+ const identity = {
190
+ ...(claudeSlot ? { claude: claudeSlot } : {}),
191
+ ...(codexSlot ? { codex: codexSlot } : {}),
192
+ ...(githubLogin ? { githubLogin } : {}),
193
+ ...(displayName ? { displayName } : {})
194
+ };
195
+ if (Object.keys(identity).length === 0)
196
+ return undefined;
197
+ const label = displayName ?? email ?? githubLogin;
198
+ log.success(`Attributing sessions to ${pc.cyan(String(label))}${githubLogin ? pc.dim(` (@${githubLogin})`) : ""}.`);
199
+ return identity;
200
+ }
201
+ // Exchange the master password for a client session token at the login endpoint. Returns null on a
202
+ // rejected password or any network error (the caller re-prompts).
203
+ async function login(serverUrl, password) {
204
+ try {
205
+ const res = await fetch(`${serverUrl}/api/auth/login`, {
206
+ method: "POST",
207
+ headers: { "content-type": "application/json" },
208
+ body: JSON.stringify({ password, label: hostname() }),
209
+ signal: AbortSignal.timeout(5_000)
210
+ });
211
+ if (!res.ok)
212
+ return null;
213
+ const body = (await res.json());
214
+ return body.token ?? null;
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ function readClaudeSettings() {
221
+ if (!existsSync(CLAUDE_SETTINGS_PATH))
222
+ return {};
223
+ try {
224
+ return JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
225
+ }
226
+ catch {
227
+ throw new Error(`${CLAUDE_SETTINGS_PATH} is not valid JSON — fix or remove it, then re-run setup.`);
228
+ }
229
+ }
230
+ function writeClaudeSettings(settings) {
231
+ mkdirSync(dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
232
+ writeFileSync(CLAUDE_SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
233
+ }
234
+ // Prefer the bare `finius <sub>` once the CLI is on PATH (survives upgrades, matches `npm i -g`).
235
+ // Otherwise fall back to an absolute `node <cli> <sub>` so the hook still works from this install.
236
+ function hookCommand(onPath, sub) {
237
+ if (onPath)
238
+ return `finius ${sub}`;
239
+ const cliEntry = join(dirname(fileURLToPath(import.meta.url)), "index.js");
240
+ return `${quote(process.execPath)} ${quote(cliEntry)} ${sub}`;
241
+ }
242
+ // Claude Code keeps its config under ~/.claude; its presence is our "installed" signal.
243
+ function isClaudeInstalled() {
244
+ return existsSync(join(homedir(), ".claude"));
245
+ }
246
+ // Configure Claude Code: OTEL env vars + the transcript-upload hook, both opt-in.
247
+ async function configureClaude(serverUrl, credential, onPath, canImport) {
248
+ log.step(pc.bold("Claude Code") + pc.dim(` — edits ${CLAUDE_SETTINGS_PATH}`));
249
+ const wantEnv = ask(await confirm({ message: "Add OpenTelemetry env vars (so Claude Code reports usage)?" }));
250
+ const wantHook = ask(await confirm({ message: "Install the SessionEnd/PreCompact hook (so transcripts upload)?" }));
251
+ if (wantEnv || wantHook) {
252
+ const settings = readClaudeSettings();
253
+ if (wantEnv)
254
+ withTelemetryEnv(settings, serverUrl, credential);
255
+ if (wantHook)
256
+ withFiniusHook(settings, hookCommand(onPath, "hook"));
257
+ writeClaudeSettings(settings);
258
+ const lines = [`Updated ${pc.dim(CLAUDE_SETTINGS_PATH)}`];
259
+ if (wantEnv)
260
+ lines.push(`${pc.green("•")} OTEL env vars set under \`env\``);
261
+ if (wantHook)
262
+ lines.push(`${pc.green("•")} Hook installed for ${TELEMETRY_HOOK_EVENTS.join(" + ")} under \`hooks\``);
263
+ lines.push(pc.dim("Restart any open Claude Code sessions for the changes to take effect."));
264
+ log.success(lines.join("\n"));
265
+ }
266
+ else {
267
+ log.info("Skipped Claude Code configuration.");
268
+ }
269
+ // The hook only catches future sessions; offer to import what's already on disk (one at a time),
270
+ // but only when the server is reachable. Otherwise the uploads would fail immediately.
271
+ if (canImport && ask(await confirm({ message: "Import your existing Claude sessions now?" }))) {
272
+ await backfill(findClaudeTranscripts(), { source: "claude-code-jsonl", format: "claude", label: "Claude sessions" });
273
+ }
274
+ }
275
+ // Configure Codex: the Stop hook (uploads rollouts), optional OTEL log capture, and a one-time
276
+ // backfill of existing sessions. Codex config is TOML; we own a clearly-delimited managed block.
277
+ async function configureCodex(serverUrl, credential, onPath, canImport) {
278
+ log.step(pc.bold("Codex") + pc.dim(` — edits ${CODEX_CONFIG_PATH}`));
279
+ const wantHook = ask(await confirm({ message: "Install the Stop hook (upload Codex rollouts to Finius)?" }));
280
+ const wantOtel = ask(await confirm({ message: "Enable OpenTelemetry log capture (event inspection only)?", initialValue: false }));
281
+ if (wantHook || wantOtel) {
282
+ const result = applyCodexConfig({
283
+ hookCommand: wantHook ? hookCommand(onPath, "codex-hook") : undefined,
284
+ otlpLogsEndpoint: wantOtel ? `${serverUrl}/otlp/v1/logs` : undefined,
285
+ // In Secure Mode the exporter must authenticate; otherwise the server would 401 every batch.
286
+ authToken: wantOtel ? credential : undefined
287
+ });
288
+ const lines = [];
289
+ if (result.changed)
290
+ lines.push(`Updated ${pc.dim(CODEX_CONFIG_PATH)}`);
291
+ if (result.addedHook)
292
+ lines.push(`${pc.green("•")} Stop hook installed (runs \`${hookCommand(onPath, "codex-hook")}\`)`);
293
+ if (result.addedOtel)
294
+ lines.push(`${pc.green("•")} [otel] exporter → Finius logs endpoint`);
295
+ if (result.skippedHook) {
296
+ lines.push(`${pc.yellow("•")} You already define [hooks] in config.toml — left it untouched (add the Stop hook manually).`);
297
+ }
298
+ if (result.skippedOtel)
299
+ lines.push(`${pc.yellow("•")} You already define [otel] in config.toml — left it untouched.`);
300
+ lines.push(pc.dim("Restart Codex for the changes to take effect."));
301
+ log.success(lines.join("\n"));
302
+ }
303
+ else {
304
+ log.info("Skipped Codex configuration.");
305
+ }
306
+ // The hook only catches future sessions; offer to import what's already on disk (one at a time),
307
+ // but only when the server is reachable. Otherwise the uploads would fail immediately.
308
+ if (canImport && ask(await confirm({ message: "Import your existing Codex sessions now?" }))) {
309
+ await backfill(findCodexRollouts(), { source: CODEX_SOURCE, format: "codex", label: "Codex sessions" });
310
+ }
311
+ }
312
+ function quote(value) {
313
+ return /[\s"']/.test(value) ? `"${value.replace(/(["\\])/g, "\\$1")}"` : value;
314
+ }
package/dist/cli/ui.js ADDED
@@ -0,0 +1,15 @@
1
+ import { cancel, isCancel } from "@clack/prompts";
2
+ import { banner, panel, pc } from "../branding.js";
3
+ // CLI-facing presentation: the shared brand banner (re-exported from ../branding) plus a guard that
4
+ // turns @clack/prompts' cancel sentinel (Ctrl-C / ESC) into a clean exit, so callers can treat prompt
5
+ // results as plain values.
6
+ // Unwrap a @clack/prompts result, aborting the whole command if the user cancelled. This lets the
7
+ // call sites read `const url = ask(await text(...))` without sprinkling isCancel checks everywhere.
8
+ export function ask(value, message = "Cancelled.") {
9
+ if (isCancel(value)) {
10
+ cancel(message);
11
+ process.exit(1);
12
+ }
13
+ return value;
14
+ }
15
+ export { banner, panel, pc };
@@ -0,0 +1 @@
1
+ code[class*=language-],pre[class*=language-]{color:#c9d1d9;background:none;font-family:ui-monospace,JetBrains Mono,SFMono-Regular,Menlo,Monaco,Consolas,monospace;text-shadow:none;line-height:1.5;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-hyphens:none;hyphens:none}.token.comment,.token.prolog,.token.cdata{color:#8b949e;font-style:italic}.token.punctuation{color:#c9d1d9}.token.namespace{opacity:.7}.token.boolean,.token.number,.token.constant,.token.symbol{color:#79c0ff}.token.deleted{color:#ffa198}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin,.token.inserted{color:#a5d6ff}.token.operator,.token.entity,.token.url,.language-css .token.string,.style .token.string,.token.atrule,.token.attr-value,.token.keyword{color:#ff7b72}.token.function,.token.class-name{color:#d2a8ff}.token.regex,.token.important,.token.variable{color:#ffa657}.token.important,.token.bold{font-weight:600;color:#c9d1d9}.token.italic{font-style:italic}.token.entity{cursor:help}.token.title,.token.title .token.punctuation{color:#d2a8ff;font-weight:700}.token.list.punctuation{color:#d2a8ff}.token.url-reference,.token.url .token.url,.token.url .token.content{color:#79c0ff;text-decoration:underline;text-underline-offset:2px}.token.code-snippet,.language-markdown .token.code,.language-markdown .token.code .token.code-block{color:#ffa657;background:#6e768133;border-radius:.25em;padding:.05em .3em}.token.blockquote.punctuation{color:#8b949e}.language-yaml .token.key{color:#79c0ff}.language-yaml .token.anchor,.language-yaml .token.alias{color:#d2a8ff}.language-yaml .token.tag{color:#ffa657}.token.template-block{color:#ffa657;background:#ffa6571f;border:1px solid rgba(255,166,87,.3);border-radius:.25em;padding:0 .2em;font-weight:500}.token.bold .token.template-block,.token.italic .token.template-block,.token.title .token.template-block{color:#ffa657}