codeshark-cli 0.1.1 → 0.1.4

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/dist/banner.js CHANGED
@@ -1,65 +1,133 @@
1
1
  import { hex, bold, dim, stripAnsi } from "./ansi.js";
2
2
  import { modelLabel, loadConfig, activeModelId } from "./config.js";
3
3
  import { findModel } from "./models.js";
4
- /**
5
- * A front-facing pixel shark head, reproduced from the 16×16 mascot artwork:
6
- * a rounded dome, two eyes, and one open mouth with pointy zigzag teeth —
7
- * white triangular points separated by red gaps, over a white lower jaw.
8
- * There is no badge background; the sprite floats directly on the terminal's
9
- * own background.
10
- */
11
- const SPRITE_WIDTH = 16;
12
- function spriteRow(value) {
13
- if (value.length !== SPRITE_WIDTH) {
14
- throw new Error(`Mascot row is ${value.length} cells; expected ${SPRITE_WIDTH}`);
15
- }
16
- return value;
17
- }
18
- /**
19
- * Character grid:
20
- * '.' transparent (terminal background)
21
- * 'D' head
22
- * 'E' eye
23
- * 'W' teeth
24
- * 'R' mouth interior
25
- */
26
- export const SPRITE = [
27
- spriteRow(".......D........"),
28
- spriteRow("......DDDD......"),
29
- spriteRow("....DDDDDDDD...."),
30
- spriteRow("...DDDDDDDDDD..."),
31
- spriteRow("...DDDDDDDDDD..."),
32
- spriteRow("..DDDDDDDDDDDD.."),
33
- spriteRow(".DDDDDDDDDDDDDD."),
34
- spriteRow(".DDEDDWWWWDDEDD."),
35
- spriteRow(".DDWDWWWWWWDWDD."),
36
- spriteRow(".DWWDWRRRRWDWWD."),
37
- spriteRow("DDWDWRRRRRRWDWDD"),
38
- spriteRow("DDWDRRRRRRRRDWDD"),
39
- spriteRow("DDWWWRWRWRWWWWDD"),
40
- spriteRow("DDWWWRWWRWWRWWDD"),
41
- spriteRow("DDWWWWWWWWWWWWDD"),
42
- spriteRow("DDWWWWWWWWWWWWDD"),
43
- ];
44
- const COLORS = {
45
- D: { fg: "#4a5a68" },
46
- E: { fg: "#0b1220" },
47
- W: { fg: "#f8fafc" },
48
- R: { fg: "#e5484d" },
4
+ const WORDMARK = "CODESHARK";
5
+ const WORDMARK_GLYPHS = {
6
+ "C": [
7
+ "01111",
8
+ "10000",
9
+ "10000",
10
+ "10000",
11
+ "10000",
12
+ "10000",
13
+ "01111"
14
+ ],
15
+ "O": [
16
+ "01110",
17
+ "10001",
18
+ "10001",
19
+ "10001",
20
+ "10001",
21
+ "10001",
22
+ "01110"
23
+ ],
24
+ "D": [
25
+ "11110",
26
+ "10001",
27
+ "10001",
28
+ "10001",
29
+ "10001",
30
+ "10001",
31
+ "11110"
32
+ ],
33
+ "E": [
34
+ "11111",
35
+ "10000",
36
+ "10000",
37
+ "11110",
38
+ "10000",
39
+ "10000",
40
+ "11111"
41
+ ],
42
+ "S": [
43
+ "01111",
44
+ "10000",
45
+ "10000",
46
+ "01110",
47
+ "00001",
48
+ "00001",
49
+ "11110"
50
+ ],
51
+ "H": [
52
+ "10001",
53
+ "10001",
54
+ "10001",
55
+ "11111",
56
+ "10001",
57
+ "10001",
58
+ "10001"
59
+ ],
60
+ "A": [
61
+ "01110",
62
+ "10001",
63
+ "10001",
64
+ "11111",
65
+ "10001",
66
+ "10001",
67
+ "10001"
68
+ ],
69
+ "R": [
70
+ "11110",
71
+ "10001",
72
+ "10001",
73
+ "11110",
74
+ "10100",
75
+ "10010",
76
+ "10001"
77
+ ],
78
+ "K": [
79
+ "10001",
80
+ "10010",
81
+ "10100",
82
+ "11000",
83
+ "10100",
84
+ "10010",
85
+ "10001"
86
+ ]
49
87
  };
50
- function renderSpriteCell(ch, plain) {
51
- if (ch === ".")
52
- return " ";
53
- if (plain) {
54
- if (ch === "E")
55
- return " ";
56
- if (ch === "W")
57
- return "░░";
58
- if (ch === "R")
59
- return "▒▒";
60
- return "██";
61
- }
62
- return hex(COLORS[ch]?.fg ?? "#4a5a68", "██");
88
+ function interpolate(start, end, amount) {
89
+ return Math.round(start + (end - start) * amount);
90
+ }
91
+ function gradientColor(index, length) {
92
+ const stops = [
93
+ ["#0b2a5b", 11, 42, 91],
94
+ ["#1677c8", 22, 119, 200],
95
+ ["#9bdcff", 155, 220, 255],
96
+ ];
97
+ const amount = length <= 1 ? 0 : index / (length - 1);
98
+ const start = amount <= 0.5 ? stops[0] : stops[1];
99
+ const end = amount <= 0.5 ? stops[1] : stops[2];
100
+ const segmentAmount = amount <= 0.5 ? amount * 2 : (amount - 0.5) * 2;
101
+ const [, startR, startG, startB] = start;
102
+ const [, endR, endG, endB] = end;
103
+ return `#${[interpolate(startR, endR, segmentAmount), interpolate(startG, endG, segmentAmount), interpolate(startB, endB, segmentAmount)]
104
+ .map((value) => value.toString(16).padStart(2, "0"))
105
+ .join("")}`;
106
+ }
107
+ function renderWordmark(plain, columns) {
108
+ // Leave a spare column to avoid terminal auto-wrap.
109
+ if (columns < 56)
110
+ return plain ? WORDMARK : bold(hex("#9bdcff", WORDMARK));
111
+ const pixelWidth = columns >= 101 ? 2 : 1;
112
+ const letters = [...WORDMARK.toUpperCase()];
113
+ const sharkStart = 4;
114
+ const sharkLength = letters.length - sharkStart;
115
+ return Array.from({ length: 7 }, (_, row) => {
116
+ const parts = [];
117
+ letters.forEach((letter, letterIndex) => {
118
+ const glyph = WORDMARK_GLYPHS[letter];
119
+ const pixels = [...glyph[row]].map((pixel) => {
120
+ if (pixel === "0")
121
+ return " ".repeat(pixelWidth);
122
+ const block = "\u2588".repeat(pixelWidth);
123
+ if (plain)
124
+ return block;
125
+ return letterIndex < sharkStart ? bold(hex("#f8fafc", block)) : hex(gradientColor(letterIndex - sharkStart, sharkLength), block);
126
+ });
127
+ parts.push(pixels.join(""));
128
+ });
129
+ return parts.join(" ");
130
+ }).join("\n");
63
131
  }
64
132
  export function renderBanner(opts = {}) {
65
133
  const plain = opts.plain ?? false;
@@ -68,20 +136,10 @@ export function renderBanner(opts = {}) {
68
136
  const baseLabel = modelLabel(cfg);
69
137
  const modelLine = opts.modelLine ?? (entry ? `${baseLabel} · ${entry.context} context` : baseLabel);
70
138
  const cwd = opts.cwd ?? process.cwd();
71
- const title = plain ? "CodeShark" : bold("CodeShark");
139
+ const title = renderWordmark(plain, opts.columns ?? process.stdout.columns ?? 80);
72
140
  const sub = plain ? modelLine : hex("#8ba3ba", modelLine);
73
141
  const dir = plain ? cwd : dim(cwd);
74
- const lines = [];
75
- const textLines = [title, sub, dir];
76
- for (let i = 0; i < SPRITE.length; i++) {
77
- let line = " ";
78
- for (const ch of SPRITE[i])
79
- line += renderSpriteCell(ch, plain);
80
- const text = textLines[i];
81
- if (text !== undefined)
82
- line += " " + text;
83
- lines.push(line);
84
- }
142
+ const lines = [...title.split("\n").map((line) => " " + line), "", " " + sub, " " + dir];
85
143
  if (opts.footer)
86
144
  lines.push("", opts.footer);
87
145
  return lines.join("\n") + "\n";
@@ -90,5 +148,6 @@ export function printBanner(opts = {}) {
90
148
  process.stdout.write(renderBanner(opts));
91
149
  }
92
150
  export function bannerHasTitle(text) {
93
- return stripAnsi(text).includes("CodeShark");
151
+ const clean = stripAnsi(text);
152
+ return clean.includes(WORDMARK) || [56, 101].some((columns) => clean.includes(renderWordmark(true, columns).split("\n").map((line) => " " + line).join("\n")));
94
153
  }
package/dist/config.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, renameSync, unlinkSync } from "node:fs";
3
4
  import { dirname, join } from "node:path";
4
- import { DEFAULT_MODEL_ID, findModel, toApiSlug } from "./models.js";
5
+ import { DEFAULT_MODEL_ID, findModel, isRetiredModel, toApiSlug } from "./models.js";
5
6
  export const DEFAULT_GATEWAY_URL = "https://codeshark-gateway.ajrgp.workers.dev";
6
7
  /** Kept for backwards compatibility; the catalog in src/models.ts is canonical. */
7
- export const DEFAULT_MODEL = "glm-5.3-flash-thinking:free";
8
+ export const DEFAULT_MODEL = "glm-5.3-flash-think-search:free";
8
9
  /** Fallback slug for OpenRouter when the active model lives on another provider. */
9
10
  export const DEFAULT_OPENROUTER_MODEL = "z-ai/glm-5.2:free";
10
11
  export const DEFAULT_GEMINI_MODEL = "gemini-2.5-pro";
@@ -20,7 +21,22 @@ export function loadConfig() {
20
21
  if (!existsSync(p))
21
22
  return {};
22
23
  const raw = readFileSync(p, "utf8");
23
- return JSON.parse(raw);
24
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
25
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
26
+ return {};
27
+ const cfg = parsed;
28
+ if (cfg.maxIterations !== undefined && (!Number.isSafeInteger(cfg.maxIterations) || cfg.maxIterations < 1))
29
+ delete cfg.maxIterations;
30
+ const strings = ["model", "systemPrompt", "openrouterApiKey", "openrouterBaseUrl", "unorouterApiKey", "unorouterBaseUrl", "nvidiaApiKey", "nvidiaBaseUrl", "geminiApiKey", "geminiBaseUrl", "gatewayUrl", "gatewayKey", "ollamaBaseUrl", "ollamaModel"];
31
+ for (const key of strings)
32
+ if (cfg[key] !== undefined && typeof cfg[key] !== "string")
33
+ delete cfg[key];
34
+ for (const key of ["termsAccepted", "setupCompleted"])
35
+ if (typeof cfg[key] !== "boolean")
36
+ delete cfg[key];
37
+ if (cfg.provider && !["gateway", "openrouter", "nvidia", "gemini", "ollama", "unorouter"].includes(cfg.provider))
38
+ delete cfg.provider;
39
+ return cfg;
24
40
  }
25
41
  catch {
26
42
  return {};
@@ -30,7 +46,17 @@ export function loadConfig() {
30
46
  export function saveConfig(cfg) {
31
47
  const p = configPath();
32
48
  mkdirSync(dirname(p), { recursive: true });
33
- writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
49
+ const temporary = p + "." + randomUUID() + ".tmp";
50
+ try {
51
+ writeFileSync(temporary, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
52
+ renameSync(temporary, p);
53
+ }
54
+ finally {
55
+ try {
56
+ unlinkSync(temporary);
57
+ }
58
+ catch { /* Already renamed, or never created. */ }
59
+ }
34
60
  try {
35
61
  chmodSync(p, 0o600);
36
62
  }
@@ -43,9 +69,9 @@ export function saveConfig(cfg) {
43
69
  * Falls back sensibly when config/env only name a provider.
44
70
  */
45
71
  export function activeModelId(cfg) {
46
- if (process.env.CODESHARK_MODEL)
72
+ if (process.env.CODESHARK_MODEL && !isRetiredModel(process.env.CODESHARK_MODEL))
47
73
  return process.env.CODESHARK_MODEL;
48
- if (cfg.model)
74
+ if (cfg.model && !isRetiredModel(cfg.model))
49
75
  return cfg.model;
50
76
  return DEFAULT_MODEL_ID;
51
77
  }
@@ -74,7 +100,8 @@ export function activeProvider(cfg) {
74
100
  /** Effective model for a provider, honoring env overrides and config. */
75
101
  export function effectiveModel(cfg, provider) {
76
102
  if (provider === "gemini") {
77
- const configured = cfg.model && !findModel(cfg.model) ? cfg.model : undefined;
103
+ const selected = findModel(activeModelId(cfg));
104
+ const configured = selected?.provider === "gemini" ? selected.model : cfg.model && !selected ? cfg.model : undefined;
78
105
  return process.env.GEMINI_MODEL ?? configured ?? DEFAULT_GEMINI_MODEL;
79
106
  }
80
107
  if (provider === "ollama")
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import { runAgent } from "./agent.js";
15
15
  import { errorMessage } from "./provider/types.js";
16
16
  import { launchKeysPage } from "./keysPage.js";
17
17
  import { extractFolderArg, openProjectFolder } from "./project.js";
18
+ import { installLatestVersion, isNewerVersion, latestPublishedVersion } from "./update.js";
19
+ import { checkModelAvailability, modelAvailabilitySnapshot } from "./modelAvailability.js";
18
20
  const require = createRequire(import.meta.url);
19
21
  const VERSION = require("../package.json").version;
20
22
  function printHelp() {
@@ -27,10 +29,11 @@ function printHelp() {
27
29
  " codeshark <prompt…> one-shot prompt for the current project folder",
28
30
  " codeshark --folder <path> open a specific project folder first",
29
31
  " codeshark --folder <path> \"prompt…\" run a prompt in that folder",
30
- " codeshark banner [--plain] print just the mascot banner",
32
+ " codeshark banner [--plain] print just the CodeShark wordmark",
31
33
  " codeshark setup guided setup for providers / API keys",
32
34
  " codeshark keys open the password-protected local key page",
33
35
  " codeshark model list and switch the model catalog",
36
+ " codeshark update check for and install the latest published version",
34
37
  " codeshark terms read the Terms of Service",
35
38
  " codeshark --version print the version",
36
39
  "",
@@ -39,11 +42,38 @@ function printHelp() {
39
42
  "",
40
43
  ].join("\n"));
41
44
  }
45
+ async function updatePackage() {
46
+ console.log(dim("Checking npm for a newer CodeShark version…"));
47
+ const latest = await latestPublishedVersion();
48
+ if (!latest) {
49
+ console.log(dim("Could not check npm right now. You can retry with `codeshark update`."));
50
+ return;
51
+ }
52
+ if (!isNewerVersion(VERSION, latest)) {
53
+ console.log(dim(`CodeShark ${VERSION} is up to date.`));
54
+ return;
55
+ }
56
+ const rl = createInterface({ input, output });
57
+ const answer = await rl.question(`CodeShark ${latest} is available (you have ${VERSION}). Install it now? [y/N]: `);
58
+ rl.close();
59
+ if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") {
60
+ console.log(dim("Update skipped."));
61
+ return;
62
+ }
63
+ try {
64
+ await installLatestVersion();
65
+ console.log(hex("#4ade80", `Updated to CodeShark ${latest}. Restart CodeShark to use it.`));
66
+ }
67
+ catch (e) {
68
+ console.log(hex("#f87171", `Update failed: ${errorMessage(e)}`));
69
+ }
70
+ }
42
71
  function isTTY() {
43
72
  return Boolean(process.stdout.isTTY) && !process.env.CODESHARK_NO_BANNER;
44
73
  }
45
74
  async function runOneShot(prompt) {
46
75
  const cfg = loadConfig();
76
+ await checkModelAvailability(cfg);
47
77
  const cwd = process.cwd();
48
78
  const registry = createRegistry();
49
79
  const clients = resolveClients(cfg, (m) => console.error(dim(m)));
@@ -59,8 +89,19 @@ async function runOneShot(prompt) {
59
89
  process.stdout.write(d);
60
90
  },
61
91
  };
92
+ const approvalRl = process.stdin.isTTY ? createInterface({ input, output }) : undefined;
62
93
  try {
63
- const result = await runAgent(prompt, { clients, registry, cwd }, events);
94
+ const result = await runAgent(prompt, {
95
+ clients,
96
+ registry,
97
+ cwd,
98
+ approveToolCall: approvalRl
99
+ ? async (call) => {
100
+ const answer = await approvalRl.question(`\nApprove ${call.name}? [y/N]: `);
101
+ return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
102
+ }
103
+ : async () => false,
104
+ }, events);
64
105
  if (thinking) {
65
106
  thinking = false;
66
107
  stopThinking();
@@ -79,6 +120,9 @@ async function runOneShot(prompt) {
79
120
  console.error(hex("#f87171", `✗ ${errorMessage(e)}`));
80
121
  process.exitCode = 1;
81
122
  }
123
+ finally {
124
+ approvalRl?.close();
125
+ }
82
126
  }
83
127
  async function main() {
84
128
  const parsed = extractFolderArg(process.argv.slice(2));
@@ -101,12 +145,13 @@ async function main() {
101
145
  console.log(readTerms());
102
146
  return;
103
147
  }
104
- // Every operational command is explicitly project-scoped. Requiring the
105
- // folder argument makes it impossible to start the agent in the wrong place.
106
- if (!parsed.folder) {
107
- throw new Error("CodeShark needs a project folder. Open your project in a terminal, then run `codeshark --folder .` (or pass its full path).");
148
+ if (command === "update") {
149
+ await updatePackage();
150
+ return;
108
151
  }
109
- const projectFolder = openProjectFolder(parsed.folder);
152
+ // Plain `codeshark` uses the current directory; all agent work remains
153
+ // scoped to that directory, which must be a real folder.
154
+ const projectFolder = openProjectFolder(parsed.folder ?? process.cwd());
110
155
  switch (command) {
111
156
  case "setup":
112
157
  await runSetup();
@@ -120,6 +165,7 @@ async function main() {
120
165
  }
121
166
  case "model": {
122
167
  // `codeshark model` lists the catalog; `codeshark model <name>` switches.
168
+ await checkModelAvailability(loadConfig());
123
169
  if (rest.length) {
124
170
  switchModel(rest.join(" "));
125
171
  return;
@@ -163,12 +209,33 @@ async function main() {
163
209
  process.env.OPENROUTER_API_KEY ||
164
210
  process.env.NVIDIA_API_KEY ||
165
211
  process.env.GEMINI_API_KEY);
166
- if (!hasProviderSetup) {
212
+ const setupCompleted = cfg.setupCompleted ?? hasProviderSetup;
213
+ if (!setupCompleted) {
167
214
  const rl = createInterface({ input, output });
168
- await runSetupFlow(cfg, rl, { title: "🦈 Welcome to CodeShark" });
215
+ await runSetupFlow(cfg, rl, { title: "Welcome to CodeShark" });
169
216
  rl.close();
217
+ cfg.setupCompleted = true;
218
+ saveConfig(cfg);
170
219
  console.log("");
171
220
  }
221
+ if (process.env.CODESHARK_NO_UPDATE === undefined) {
222
+ const latest = await latestPublishedVersion();
223
+ if (latest && isNewerVersion(VERSION, latest)) {
224
+ const rl = createInterface({ input, output });
225
+ const answer = await rl.question(` CodeShark ${latest} is available. Install it now? [y/N]: `);
226
+ rl.close();
227
+ if (answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes") {
228
+ try {
229
+ await installLatestVersion();
230
+ console.log(hex("#4ade80", ` Updated to CodeShark ${latest}. Restart to use the new version.`));
231
+ }
232
+ catch (e) {
233
+ console.log(hex("#f87171", ` Update failed: ${errorMessage(e)}`));
234
+ }
235
+ }
236
+ }
237
+ }
238
+ await checkModelAvailability(loadConfig());
172
239
  // The REPL prints the input instructions once after the banner. Keeping
173
240
  // them out of the banner avoids the duplicated startup line. The loading
174
241
  // screen types itself out with a spinner, then hands off to the REPL.
@@ -178,6 +245,15 @@ async function main() {
178
245
  "Loading model catalog",
179
246
  "Starting the agent",
180
247
  ]);
248
+ console.log(dim(" Model health"));
249
+ for (const { model, status, reason } of modelAvailabilitySnapshot()) {
250
+ if (status === "available") {
251
+ console.log(` ${hex("#4ade80", "✓ Available")} ${model.label}`);
252
+ }
253
+ else {
254
+ console.log(` ${hex("#f87171", "✗ Unavailable")} ${model.label}${reason ? dim(` — ${reason}`) : ""}`);
255
+ }
256
+ }
181
257
  console.log("");
182
258
  }
183
259
  const registry = createRegistry();
package/dist/keysPage.js CHANGED
@@ -107,14 +107,14 @@ body{max-width:760px;margin:0 auto;padding:42px 22px}h1{font-size:25px;margin:0
107
107
  function loginPage(message = "") {
108
108
  const auth = readAuth();
109
109
  if (!auth) {
110
- return page("Create local password", `<h1>🦈 CodeShark key vault</h1><p>Set a local password before viewing API keys. This password is stored as a one-way hash and never leaves this computer.</p>${message ? `<p class="warning">${htmlEscape(message)}</p>` : ""}<div class="card"><form method="post" action="/setup"><label for="password">Create password</label><input id="password" name="password" type="password" minlength="8" required autofocus><label for="confirm">Confirm password</label><input id="confirm" name="confirm" type="password" minlength="8" required><button>Protect my keys</button></form></div><p class="small">The page only listens on 127.0.0.1. Do not expose this port publicly.</p>`);
110
+ return page("Create local password", `<h1>CodeShark key vault</h1><p>Set a local password before viewing API keys. This password is stored as a one-way hash and never leaves this computer.</p>${message ? `<p class="warning">${htmlEscape(message)}</p>` : ""}<div class="card"><form method="post" action="/setup"><label for="password">Create password</label><input id="password" name="password" type="password" minlength="8" required autofocus><label for="confirm">Confirm password</label><input id="confirm" name="confirm" type="password" minlength="8" required><button>Protect my keys</button></form></div><p class="small">The page only listens on 127.0.0.1. Do not expose this port publicly.</p>`);
111
111
  }
112
- return page("Unlock key vault", `<h1>🦈 CodeShark key vault</h1><p>Enter your local password to view or edit saved provider keys.</p>${message ? `<p class="warning">${htmlEscape(message)}</p>` : ""}<div class="card"><form method="post" action="/login"><input type="hidden" name="csrf" value="${htmlEscape(randomBytes(16).toString("hex"))}"><label for="password">Password</label><input id="password" name="password" type="password" required autofocus><button>Unlock</button></form></div><p class="small">Forgot the password? Delete ${htmlEscape(authFilePath ?? authPath())} to reset local protection.</p>`);
112
+ return page("Unlock key vault", `<h1>CodeShark key vault</h1><p>Enter your local password to view or edit saved provider keys.</p>${message ? `<p class="warning">${htmlEscape(message)}</p>` : ""}<div class="card"><form method="post" action="/login"><input type="hidden" name="csrf" value="${htmlEscape(randomBytes(16).toString("hex"))}"><label for="password">Password</label><input id="password" name="password" type="password" required autofocus><button>Unlock</button></form></div><p class="small">Forgot the password? Delete ${htmlEscape(authFilePath ?? authPath())} to reset local protection.</p>`);
113
113
  }
114
114
  function vaultPage(session) {
115
115
  const cfg = loadConfig();
116
116
  const secretInput = (id, label, placeholder) => `<label for="${id}">${label}</label><input id="${id}" name="${id}" type="password" value="" placeholder="${placeholder}" autocomplete="new-password" spellcheck="false"><label class="small"><input type="checkbox" name="clear_${id}" value="1" style="width:auto;margin-right:8px"> Clear this saved key</label>`;
117
- return page("API keys", `<h1>🦈 CodeShark key vault</h1><p>Unlocked locally. Values are saved to <code>${htmlEscape(configPath())}</code>. Keep this page on your own computer.</p><div class="card"><form method="post" action="/save"><input type="hidden" name="csrf" value="${htmlEscape(session.csrf)}"><h2>Provider keys</h2>${secretInput("unorouterApiKey", "UnoRouter API key", "paste a replacement (shown once)…")}${secretInput("openrouterApiKey", "OpenRouter API key", "paste a replacement: sk-or-v1-…")}${secretInput("nvidiaApiKey", "NVIDIA NIM API key", "paste a replacement: nvapi-…")}${secretInput("geminiApiKey", "Google Gemini API key", "paste a replacement: AIza…")}<p class="small">Saved keys are never placed in this page's HTML. Leave a field blank to keep its current value, or check Clear to remove it.</p><button>Save keys</button></form></div><div class="card"><h2>Current status</h2><p class="key">UnoRouter: ${htmlEscape(mask(cfg.unorouterApiKey) || "not set")}</p><p class="key">OpenRouter: ${htmlEscape(mask(cfg.openrouterApiKey) || "not set")}</p><p class="key">NVIDIA: ${htmlEscape(mask(cfg.nvidiaApiKey) || "not set")}</p><p class="key">Gemini: ${htmlEscape(mask(cfg.geminiApiKey) || "not set")}</p><p class="hint">Your keys are never printed to the terminal by CodeShark.</p><form method="post" action="/logout"><input type="hidden" name="csrf" value="${htmlEscape(session.csrf)}"><button class="danger">Lock vault</button></form></div>`);
117
+ return page("API keys", `<h1>CodeShark key vault</h1><p>Unlocked locally. Values are saved to <code>${htmlEscape(configPath())}</code>. Keep this page on your own computer.</p><div class="card"><form method="post" action="/save"><input type="hidden" name="csrf" value="${htmlEscape(session.csrf)}"><h2>Provider keys</h2>${secretInput("unorouterApiKey", "Primary model API key", "paste a replacement (shown once)…")}${secretInput("openrouterApiKey", "OpenRouter API key", "paste a replacement: sk-or-v1-…")}${secretInput("nvidiaApiKey", "NVIDIA NIM API key", "paste a replacement: nvapi-…")}${secretInput("geminiApiKey", "Gemini API key", "paste a replacement: AIza…")}<p class="small">Saved keys are never placed in this page's HTML. Leave a field blank to keep its current value, or check Clear to remove it.</p><button>Save keys</button></form></div><div class="card"><h2>Current status</h2><p class="key">Primary model API: ${htmlEscape(mask(cfg.unorouterApiKey) || "not set")}</p><p class="key">OpenRouter: ${htmlEscape(mask(cfg.openrouterApiKey) || "not set")}</p><p class="key">NVIDIA: ${htmlEscape(mask(cfg.nvidiaApiKey) || "not set")}</p><p class="key">Gemini: ${htmlEscape(mask(cfg.geminiApiKey) || "not set")}</p><p class="hint">Your keys are never printed to the terminal by CodeShark.</p><form method="post" action="/logout"><input type="hidden" name="csrf" value="${htmlEscape(session.csrf)}"><button class="danger">Lock vault</button></form></div>`);
118
118
  }
119
119
  function parseBody(req) {
120
120
  return new Promise((resolve, reject) => {
@@ -0,0 +1,86 @@
1
+ import { DEFAULT_GATEWAY_URL, envApiKey } from "./config.js";
2
+ import { MODELS } from "./models.js";
3
+ const statuses = new Map();
4
+ const reasons = new Map();
5
+ const TIMEOUT_MS = 8_000;
6
+ export function modelAvailability(modelId) {
7
+ return statuses.get(modelId) ?? "checking";
8
+ }
9
+ export function modelAvailabilityReason(modelId) {
10
+ return reasons.get(modelId);
11
+ }
12
+ export function modelAvailabilitySnapshot() {
13
+ return MODELS.map((model) => ({
14
+ model,
15
+ status: modelAvailability(model.id),
16
+ reason: modelAvailabilityReason(model.id),
17
+ }));
18
+ }
19
+ function endpointFor(model, cfg) {
20
+ if (model.provider === "gemini") {
21
+ return {
22
+ url: `${(cfg.geminiBaseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "")}/models/${model.model}:generateContent`,
23
+ key: cfg.geminiApiKey ?? envApiKey("gemini"),
24
+ };
25
+ }
26
+ const key = cfg.unorouterApiKey ?? envApiKey("unorouter");
27
+ if (key) {
28
+ return { url: `${(cfg.unorouterBaseUrl ?? "https://api.unorouter.com/v1").replace(/\/+$/, "")}/chat/completions`, key };
29
+ }
30
+ return {
31
+ url: `${(cfg.gatewayUrl ?? process.env.CODESHARK_GATEWAY_URL ?? DEFAULT_GATEWAY_URL).replace(/\/+$/, "")}/v1/chat/completions`,
32
+ key: cfg.gatewayKey ?? process.env.CODESHARK_GATEWAY_KEY,
33
+ };
34
+ }
35
+ async function checkOne(model, cfg) {
36
+ const target = endpointFor(model, cfg);
37
+ if (!target.key && model.provider === "gemini") {
38
+ statuses.set(model.id, "unavailable");
39
+ reasons.set(model.id, "GEMINI_API_KEY is not configured");
40
+ return;
41
+ }
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
44
+ try {
45
+ const headers = { "content-type": "application/json" };
46
+ if (target.key)
47
+ headers.authorization = `Bearer ${target.key}`;
48
+ if (model.provider === "gemini") {
49
+ const url = `${target.url}?key=${encodeURIComponent(target.key ?? "")}`;
50
+ const response = await fetch(url, {
51
+ method: "POST",
52
+ headers: { "content-type": "application/json" },
53
+ body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: "Reply with OK." }] }] }),
54
+ signal: controller.signal,
55
+ });
56
+ if (!response.ok)
57
+ throw new Error(`HTTP ${response.status}`);
58
+ }
59
+ else {
60
+ const response = await fetch(target.url, {
61
+ method: "POST",
62
+ headers,
63
+ body: JSON.stringify({ model: model.model, messages: [{ role: "user", content: "Reply with OK." }], stream: false, max_tokens: 4 }),
64
+ signal: controller.signal,
65
+ });
66
+ if (!response.ok)
67
+ throw new Error(`HTTP ${response.status}`);
68
+ }
69
+ statuses.set(model.id, "available");
70
+ reasons.delete(model.id);
71
+ }
72
+ catch (error) {
73
+ statuses.set(model.id, "unavailable");
74
+ reasons.set(model.id, error instanceof Error && error.name === "AbortError" ? "check timed out" : error instanceof Error ? error.message : String(error));
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+ export async function checkModelAvailability(cfg) {
81
+ for (const model of MODELS) {
82
+ statuses.set(model.id, "checking");
83
+ reasons.delete(model.id);
84
+ }
85
+ await Promise.all(MODELS.map((model) => checkOne(model, cfg)));
86
+ }