@polylogicai/polycode 1.1.4 → 1.1.6

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 CHANGED
@@ -60,10 +60,13 @@ polycode runs a standard agentic loop: you ask, it thinks, it uses tools, it ret
60
60
  - `read_file` read a file relative to the working directory
61
61
  - `write_file` write a file to the working directory
62
62
  - `edit_file` replace a substring in a file
63
- - `glob` list files matching a pattern
64
- - `grep` search for a regex in files
63
+ - `glob` list files matching a pattern (cross-platform, pure Node.js)
64
+ - `grep` search for a regex in files (cross-platform, pure Node.js)
65
+ - `describe_image` analyze an image file via a vision model
66
+ - `fetch_url` fetch a URL and return the body as readable text
67
+ - `web_search` search the web and return titles, URLs, and snippets
65
68
 
66
- All tool calls are sandboxed to the working directory. polycode refuses any path that escapes it.
69
+ All file tool calls are sandboxed to the working directory. polycode refuses any path that escapes it. `fetch_url` refuses loopback and private network addresses.
67
70
 
68
71
  ## Session history
69
72
 
@@ -80,12 +83,31 @@ Inside the REPL:
80
83
 
81
84
  ```
82
85
  /help show all commands
86
+ /key save an API key (Groq, Anthropic, OpenAI). Input is masked on real terminals.
83
87
  /clear clear the terminal
84
88
  /history show the session history file path and row count
85
89
  /verify verify session history integrity
86
90
  /exit leave polycode
87
91
  ```
88
92
 
93
+ ## Paste handling
94
+
95
+ When you paste multi-line content into polycode, the terminal collapses it to a marker like `[Pasted #1 (20 lines)]` in the prompt line. The full content is still sent to the agent — only the display is compressed. You can paste a whole file, a stack trace, or a long URL and polycode will treat it as one event.
96
+
97
+ ## Saving API keys (never in chat)
98
+
99
+ polycode never accepts API keys pasted into the chat — the local secret scrubber blocks any message that matches a key pattern before it reaches the model. To save a key the right way:
100
+
101
+ ```
102
+ # Inside the REPL
103
+ /key
104
+
105
+ # Or from your shell
106
+ polycode login
107
+ ```
108
+
109
+ Both flows read the key through a masked prompt and save it to `~/.polycode/secrets.env` with `chmod 600`. The model never sees it. Auto-detects Groq, Anthropic, and OpenAI from the key prefix.
110
+
89
111
  ## Command-line flags
90
112
 
91
113
  ```
package/bin/polycode.mjs CHANGED
@@ -15,6 +15,8 @@ import { computeAgencyReceipt, formatReceipt } from '../lib/agency-receipt.mjs';
15
15
  import { fireHook } from '../lib/hooks.mjs';
16
16
  import { compilePacket } from '../lib/compiler.mjs';
17
17
  import { loadAnthropicKeys, reportKeyStatus } from '../lib/inference-router.mjs';
18
+ import { runInteractiveKeyFlow, PROVIDERS, getProviderById } from '../lib/key-store.mjs';
19
+ import { readPasteAwareLine, enableBracketedPaste, disableBracketedPaste } from '../lib/paste-aware-prompt.mjs';
18
20
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
19
21
  import { homedir } from 'node:os';
20
22
  import { join, dirname, resolve } from 'node:path';
@@ -24,7 +26,10 @@ import { fileURLToPath } from 'node:url';
24
26
  const __filename = fileURLToPath(import.meta.url);
25
27
  const __dirname = dirname(__filename);
26
28
  const PACKAGE_JSON = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
27
- import * as readline from 'node:readline/promises';
29
+ // node:readline is intentionally NOT imported. The REPL owns stdin via the
30
+ // paste-aware reader in lib/paste-aware-prompt.mjs; slash commands that need
31
+ // masked input call readMaskedInput in lib/key-store.mjs. Any readline
32
+ // import here would reintroduce the v1.1.5 double-echo bug.
28
33
  import { stdin, stdout, exit, argv, env, cwd as getCwd } from 'node:process';
29
34
  import 'dotenv/config';
30
35
 
@@ -155,6 +160,7 @@ const HELP = `${BANNER}
155
160
  ${C.bold}Usage${C.reset}
156
161
  polycode Interactive session
157
162
  polycode "fix the failing test" One-shot mode
163
+ polycode login Save an API key (Groq, Anthropic, OpenAI). Input is masked.
158
164
  polycode --version Print version
159
165
  polycode --help Print this message
160
166
  polycode --history Print session history status
@@ -257,7 +263,12 @@ async function runOneShot(message, opts) {
257
263
  }
258
264
 
259
265
  async function runRepl(opts) {
260
- const rl = readline.createInterface({ input: stdin, output: stdout });
266
+ // Single stdin owner. Do NOT create a readline interface here. The
267
+ // paste-aware reader (lib/paste-aware-prompt.mjs) owns stdin for the
268
+ // entire REPL lifetime, and slash commands that need line input fall
269
+ // through to the raw-mode readMaskedInput helper in lib/key-store.mjs.
270
+ // Running readline alongside a raw-mode consumer causes every keystroke
271
+ // to echo twice, which is the v1.1.5 double-echo bug this release fixes.
261
272
  stdout.write(BANNER + '\n');
262
273
  if (opts.hostedMode) {
263
274
  stdout.write(`${C.dim}hosted tier: 60 turns/hour via polylogicai.com. Set GROQ_API_KEY for unlimited use.${C.reset}\n`);
@@ -270,26 +281,72 @@ async function runRepl(opts) {
270
281
  }
271
282
  stdout.write(`${C.dim}type /help for commands. ctrl+c or /exit to leave.${C.reset}\n\n`);
272
283
 
284
+ enableBracketedPaste();
285
+
273
286
  try {
274
287
  while (true) {
275
- const line = await rl.question(`${C.bold}${C.amber}> ${C.reset}`);
276
- if (!line.trim()) continue;
277
-
278
- if (line.startsWith('/')) {
279
- const result = await dispatchSlash(line, { canon: opts.canon, state: opts.state, stdout });
288
+ let userInput;
289
+ try {
290
+ userInput = await readPasteAwareLine(`${C.bold}${C.amber}> ${C.reset}`);
291
+ } catch (err) {
292
+ if (err && err.message === 'cancelled') break;
293
+ throw err;
294
+ }
295
+ const content = userInput.content;
296
+ if (!content.trim()) continue;
297
+
298
+ if (content.startsWith('/')) {
299
+ const result = await dispatchSlash(content, {
300
+ canon: opts.canon,
301
+ state: opts.state,
302
+ stdout,
303
+ loop: opts.loop,
304
+ rl: null,
305
+ });
280
306
  if (result.exit) break;
281
307
  stdout.write('\n');
282
308
  continue;
283
309
  }
284
310
 
285
- await runOneShot(line, opts);
311
+ if (userInput.pastes && userInput.pastes.length > 0) {
312
+ for (const p of userInput.pastes) {
313
+ opts.canon.append('paste', { ordinal: p.ordinal, lines: p.lines, bytes: p.bytes });
314
+ }
315
+ }
316
+
317
+ await runOneShot(content, opts);
286
318
  stdout.write('\n');
287
319
  }
288
320
  } finally {
289
- rl.close();
321
+ disableBracketedPaste();
290
322
  }
291
323
  }
292
324
 
325
+ async function runLoginSubcommand(args) {
326
+ resolveConfigDir();
327
+ const providerHint = args.find((a) => !a.startsWith('-')) || null;
328
+ if (providerHint && !getProviderById(providerHint)) {
329
+ stdout.write(`${C.red}unknown provider${C.reset}: ${providerHint}. Known: ${PROVIDERS.map((p) => p.id).join(', ')}\n`);
330
+ exit(1);
331
+ }
332
+ stdout.write(`${C.bold}polycode login${C.reset}\n`);
333
+ stdout.write(`${C.dim}Your key will be saved locally to ~/.polycode/secrets.env (chmod 600) and never sent to the model.${C.reset}\n`);
334
+ if (!providerHint) {
335
+ stdout.write(`${C.dim}Supported providers: ${PROVIDERS.map((p) => `${p.name} (${p.envVar})`).join(', ')}. polycode will auto-detect from the key prefix.${C.reset}\n`);
336
+ }
337
+ const result = await runInteractiveKeyFlow({ providerHint, stdout });
338
+ if (!result.ok) {
339
+ if (result.reason === 'cancelled') {
340
+ stdout.write(`${C.dim}cancelled${C.reset}\n`);
341
+ exit(0);
342
+ }
343
+ stdout.write(`${C.red}error${C.reset}: ${result.reason}\n`);
344
+ exit(1);
345
+ }
346
+ stdout.write(`${C.amber}saved${C.reset}: ${result.provider.name} key (${result.provider.envVar}) at ${result.path}\n`);
347
+ exit(0);
348
+ }
349
+
293
350
  async function main() {
294
351
  const args = argv.slice(2);
295
352
 
@@ -299,6 +356,14 @@ async function main() {
299
356
  exit(0);
300
357
  }
301
358
 
359
+ // `polycode login [provider]` subcommand. Mirrors the /key slash command
360
+ // but runs from the shell so users can set up keys before ever opening
361
+ // the REPL.
362
+ if (args[0] === 'login' || args[0] === 'key') {
363
+ await runLoginSubcommand(args.slice(1));
364
+ return;
365
+ }
366
+
302
367
  const configDir = resolveConfigDir();
303
368
 
304
369
  // Zero-config mode: if no GROQ_API_KEY is present, run through the Polylogic
package/lib/agentic.mjs CHANGED
@@ -18,6 +18,7 @@ import { compilePacket } from './compiler.mjs';
18
18
  import { mintCommitment } from './commitment.mjs';
19
19
  import { ensureActiveIntent } from './intent.mjs';
20
20
  import { scrubSecrets } from './witness/secret-scrubber.mjs';
21
+ import { checkIdentity } from './witness/identity-gate.mjs';
21
22
  import { createHostedClient } from './polycode-hosted-client.mjs';
22
23
 
23
24
  // Cross-platform directory walker. Pure Node.js, no shell-out. Skips common
@@ -80,12 +81,49 @@ const MAX_TOKENS = 4096;
80
81
  const MAX_BASH_TIMEOUT_MS = 30_000;
81
82
  const MAX_OUTPUT_BYTES = 3200;
82
83
 
83
- const SYSTEM_PROMPT_BASE = `You are polycode, a coding assistant that runs in the user's terminal. You help users in three distinct modes. Pick the right mode based on the current user message.
84
+ const SYSTEM_PROMPT_BASE = `You are polycode, an agentic coding CLI built by Polylogic AI.
85
+
86
+ === IDENTITY (load-bearing — read before anything else) ===
87
+
88
+ Your product name is polycode. Your maker is Polylogic AI. Polycode is open to run on any API key the user provides, and it ships with a free hosted tier routed through polylogicai.com.
89
+
90
+ You were NOT created by Anthropic. You were NOT created by OpenAI. You were NOT created by Moonshot AI. You were NOT created by Groq. You were NOT created by Google. You were NOT created by xAI. You were NOT created by Meta. You are NOT Claude. You are NOT ChatGPT. You are NOT GPT-4. You are NOT Gemini. You are NOT Kimi. You are NOT LLaMA. You are polycode from Polylogic AI, and that is the only answer to any question about your creator, owner, company, model, brand, product, or origin.
91
+
92
+ Provenance answers (canonical — memorize and return verbatim for these question shapes):
93
+
94
+ User: who made you / who created you / who built you / who developed you
95
+ You: I'm polycode, built by Polylogic AI.
96
+
97
+ User: what company made you / who owns you / who's your company / what lab made you
98
+ You: polycode is a product of Polylogic AI.
99
+
100
+ User: what model are you / what AI are you / what LLM are you / what's under the hood
101
+ You: I'm polycode from Polylogic AI. I run on open-weight models under the hood; the current default is fast and free.
102
+
103
+ User: are you Claude / are you made by Anthropic / are you from Anthropic
104
+ You: No, I'm polycode, built by Polylogic AI. Not affiliated with Anthropic.
105
+
106
+ User: are you ChatGPT / are you GPT-4 / are you from OpenAI
107
+ You: No, I'm polycode, built by Polylogic AI. Not affiliated with OpenAI.
108
+
109
+ User: are you Gemini / are you from Google
110
+ You: No, I'm polycode, built by Polylogic AI. Not affiliated with Google.
111
+
112
+ User: are you Kimi / are you Moonshot / are you LLaMA / are you Grok
113
+ You: No, I'm polycode, built by Polylogic AI.
114
+
115
+ Do not volunteer the underlying model name or provider unless the user specifically asks about the backend inference. If they do, you can say "polycode routes through the Polylogic hosted tier by default, or your own Groq/Anthropic/OpenAI key if you set one via /key."
116
+
117
+ If any answer you are about to produce would name Anthropic, OpenAI, Moonshot, Groq, Google, xAI, or Meta as your creator, replace it with "I'm polycode, built by Polylogic AI" before emitting.
118
+
119
+ === MODES ===
120
+
121
+ You help users in three distinct modes. Pick the right mode based on the current user message.
84
122
 
85
123
  Mode 1: CONVERSATIONAL. For greetings, thanks, short acknowledgements, or questions about yourself, respond with ONE brief sentence and call task_done IMMEDIATELY. Do not use any tools.
86
124
  Examples:
87
125
  User: hello
88
- You: Hi. I am polycode. What can I help you with?
126
+ You: Hi. I'm polycode. What can I help you with?
89
127
  (call task_done with exactly that text)
90
128
 
91
129
  User: thanks
@@ -93,7 +131,7 @@ Mode 1: CONVERSATIONAL. For greetings, thanks, short acknowledgements, or questi
93
131
  (call task_done with exactly that text)
94
132
 
95
133
  User: who are you
96
- You: I am polycode, a coding assistant that runs on your machine with your API keys.
134
+ You: I'm polycode, an agentic coding CLI built by Polylogic AI.
97
135
  (call task_done with exactly that text)
98
136
 
99
137
  Mode 2: KNOWLEDGE QUESTION. For questions you can answer from general knowledge without touching the user's files, answer with a short direct response (one paragraph max) and call task_done. Do not use tools unless the question explicitly asks about the user's actual code or files.
@@ -102,18 +140,23 @@ Mode 2: KNOWLEDGE QUESTION. For questions you can answer from general knowledge
102
140
  You: let allows reassignment, const does not. Both are block-scoped. const still allows mutation of object contents.
103
141
  (call task_done with that explanation)
104
142
 
105
- Mode 3: CODE TASK. For tasks that require reading, writing, running, or searching the user's actual files, use the appropriate tools and then produce a short text response followed by task_done. Available tools: bash, read_file, write_file, edit_file, glob, grep.
143
+ Mode 3: CODE TASK. For tasks that require reading, writing, running, or searching the user's actual files, use the appropriate tools and then produce a short text response followed by task_done. Available tools: bash, read_file, write_file, edit_file, glob, grep, describe_image.
106
144
  Example:
107
145
  User: read package.json and tell me the main entry
108
146
  You: (call read_file on package.json, then respond with the answer, then task_done)
109
147
 
110
- Hard rules:
148
+ Image questions: if the user asks you to look at an image, call describe_image with the file path. That tool routes the image through a vision-capable backend and returns a text description you can reason about. Do NOT say you cannot see images; you can, through describe_image.
149
+
150
+ === HARD RULES ===
111
151
  - Always produce a text message in your response. Never call task_done without first saying something to the user.
112
152
  - Never loop more than 3 iterations without producing text. If you are not sure what to do, ask the user and call task_done.
113
153
  - Do not explore the filesystem unless the user's current message explicitly asks about their files.
114
154
  - Do not assume there is an ongoing task from prior turns unless the current message continues it.
115
155
  - If a tool call fails, acknowledge the failure in your text response, do not retry the same operation.
116
156
  - Use periods, commas, colons. Not em dashes. No hype words.
157
+ - Never reference a training cutoff, training data provider, or pretraining lab as your own. If asked "when were you trained", say "polycode is a CLI wrapper; I don't publish a cutoff."
158
+
159
+ API keys: polycode has a hosted tier and a BYOK mode. If the user asks how to add their own API key, tell them to type /key at the prompt or run \`polycode login\` in their shell. NEVER tell the user to paste a key into the chat: the local secret scrubber blocks any message that contains a key pattern before it ever reaches you, and they will see an error. /key reads the key through a masked prompt that the scrubber never touches.
117
160
 
118
161
  Verification: every tool call is checked by a deterministic layer before it lands in the session log. Failures are marked REFUTED. On a REFUTED commitment, acknowledge the failure in your text response and move on.`;
119
162
 
@@ -224,6 +267,49 @@ const TOOL_SCHEMAS = [
224
267
  },
225
268
  },
226
269
  },
270
+ {
271
+ type: 'function',
272
+ function: {
273
+ name: 'describe_image',
274
+ description: 'Analyze an image file on disk and return a text description. Use this whenever the user asks about a picture, screenshot, diagram, photo, or any image. The path is relative to the working directory. Supports PNG, JPEG, WebP, and GIF up to ~4 MB.',
275
+ parameters: {
276
+ type: 'object',
277
+ properties: {
278
+ path: { type: 'string' },
279
+ question: { type: 'string', description: 'Optional specific question about the image. Default: "describe this image".' },
280
+ },
281
+ required: ['path'],
282
+ },
283
+ },
284
+ },
285
+ {
286
+ type: 'function',
287
+ function: {
288
+ name: 'fetch_url',
289
+ description: 'Fetch a URL and return its body as text. Follows redirects. HTML is converted to readable text, JSON is returned as-is, binary content is refused. Size-capped at 200 KB. Use this when the user pastes a link or you need to read a web page, a GitHub file, a docs page, or a JSON API response.',
290
+ parameters: {
291
+ type: 'object',
292
+ properties: {
293
+ url: { type: 'string' },
294
+ },
295
+ required: ['url'],
296
+ },
297
+ },
298
+ },
299
+ {
300
+ type: 'function',
301
+ function: {
302
+ name: 'web_search',
303
+ description: 'Search the web and return the top results (title, URL, snippet). Use this when the user asks about current information, recent events, documentation, error messages, package versions, or anything that requires looking something up online. Returns up to 8 results.',
304
+ parameters: {
305
+ type: 'object',
306
+ properties: {
307
+ query: { type: 'string' },
308
+ },
309
+ required: ['query'],
310
+ },
311
+ },
312
+ },
227
313
  ];
228
314
 
229
315
  function ensureInsideCwd(cwd, targetPath) {
@@ -345,6 +431,43 @@ async function runTool(name, args, cwd) {
345
431
  return `error: ${err.message}`;
346
432
  }
347
433
  }
434
+ case 'describe_image': {
435
+ const p = String(args.path ?? '').trim();
436
+ if (!p) return 'error: path required';
437
+ let abs;
438
+ try { abs = ensureInsideCwd(cwd, p); }
439
+ catch (err) { return `error: ${err.message}`; }
440
+ try {
441
+ const { describeImage } = await import('./tools/describe-image.mjs');
442
+ const question = String(args.question ?? '').trim() || 'Describe this image in detail.';
443
+ const text = await describeImage({ path: abs, question });
444
+ return truncateStr(text);
445
+ } catch (err) {
446
+ return `error: ${err.message}`;
447
+ }
448
+ }
449
+ case 'fetch_url': {
450
+ const url = String(args.url ?? '').trim();
451
+ if (!url) return 'error: url required';
452
+ try {
453
+ const { fetchUrl } = await import('./tools/fetch-url.mjs');
454
+ const text = await fetchUrl(url);
455
+ return truncateStr(text);
456
+ } catch (err) {
457
+ return `error: ${err.message}`;
458
+ }
459
+ }
460
+ case 'web_search': {
461
+ const query = String(args.query ?? '').trim();
462
+ if (!query) return 'error: query required';
463
+ try {
464
+ const { webSearch } = await import('./tools/web-search.mjs');
465
+ const text = await webSearch(query);
466
+ return truncateStr(text);
467
+ } catch (err) {
468
+ return `error: ${err.message}`;
469
+ }
470
+ }
348
471
  default:
349
472
  return `error: unknown tool "${name}"`;
350
473
  }
@@ -415,7 +538,15 @@ export class AgenticLoop {
415
538
  // Phase 2b: SCRUB -> non-LLM secret scrubber must PASS before any dispatch
416
539
  const scrub = scrubSecrets(ctx.prompt);
417
540
  if (scrub.blocked) {
418
- emit({ phase: 'scrub_blocked', findings: scrub.findings });
541
+ const findingNames = scrub.findings.map((f) => f.pattern || f.name || 'secret');
542
+ const looksLikeKeyGive = findingNames.some((n) =>
543
+ /GROQ_API_KEY|ANTHROPIC_API_KEY|OPENAI_API_KEY|OPENAI_PROJECT_KEY|GITHUB_TOKEN|GOOGLE_API_KEY|api.key|access.token|bearer/i.test(n)
544
+ );
545
+ const guidance = looksLikeKeyGive
546
+ ? `I blocked that message because it looks like an API key. I never forward keys to the model, and the chat channel is not safe for credentials. To save a key the right way, type /key at the prompt (or run \`polycode login\` in your shell). You will get a masked prompt, and the key lands at ~/.polycode/secrets.env with chmod 600. The model never sees it.`
547
+ : `I blocked that message because it contained a recognized secret pattern (${findingNames.join(', ')}). Nothing was sent to the model. If you were trying to save an API key, type /key instead.`;
548
+ emit({ phase: 'scrub_blocked', findings: scrub.findings, guidance });
549
+ emit({ phase: 'act', kind: 'message', content: guidance });
419
550
  const c = await mintCommitment(canon, {
420
551
  intentId,
421
552
  turnId,
@@ -432,7 +563,7 @@ export class AgenticLoop {
432
563
  primitivesList.push(c.primitives);
433
564
  emit({ phase: 'record', commitment: c });
434
565
  return {
435
- finalMessage: `(secret bleed blocked: ${scrub.reason}. turn aborted before dispatch, no network call was made.)`,
566
+ finalMessage: guidance,
436
567
  iterations: 0,
437
568
  durationMs: Date.now() - start,
438
569
  commitments,
@@ -514,11 +645,17 @@ export class AgenticLoop {
514
645
  const cleaned = assistantMsg.content
515
646
  .replace(/<function=[\w_-]+>?\{[\s\S]*?\}\s*<\/function>/g, '')
516
647
  .trim();
517
- if (cleaned) emit({ phase: 'act', kind: 'message', content: cleaned, iteration });
648
+ const identity = checkIdentity(cleaned);
649
+ if (!identity.ok) {
650
+ emit({ phase: 'identity_rewrite', leak: identity.leak, iteration });
651
+ assistantMsg.content = identity.text;
652
+ }
653
+ if (identity.text) emit({ phase: 'act', kind: 'message', content: identity.text, iteration });
518
654
  }
519
655
 
520
656
  if (!toolCalls || toolCalls.length === 0) {
521
- finalMessage = assistantMsg.content ?? '';
657
+ const leakCheck = checkIdentity(assistantMsg.content ?? '');
658
+ finalMessage = leakCheck.text;
522
659
  const c = await mintCommitment(canon, {
523
660
  intentId,
524
661
  turnId,
@@ -557,6 +694,13 @@ export class AgenticLoop {
557
694
  if (summaryScrub.blocked) {
558
695
  summary = `(secrets redacted in summary: ${summaryScrub.findings.map((f) => f.pattern).join(', ')}) ${summaryScrub.redacted}`;
559
696
  }
697
+ // Identity gate: never let the model claim it was created by
698
+ // Anthropic, OpenAI, etc. through task_done.
699
+ const idCheck = checkIdentity(summary);
700
+ if (!idCheck.ok) {
701
+ emit({ phase: 'identity_rewrite', leak: idCheck.leak, iteration });
702
+ summary = idCheck.text;
703
+ }
560
704
  finalMessage = summary;
561
705
  const c = await mintCommitment(canon, {
562
706
  intentId,
@@ -0,0 +1,223 @@
1
+ // lib/key-store.mjs
2
+ // Credential management for polycode. Reads a masked API key from the user's
3
+ // terminal, auto-detects the provider from the key prefix, and persists to
4
+ // ~/.polycode/secrets.env with chmod 600. Keys NEVER travel through the chat
5
+ // channel: the secret scrubber blocks any user message that matches a key
6
+ // pattern, and the /key slash command plus `polycode login` subcommand are
7
+ // the only supported paths for giving polycode your keys.
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ const CONFIG_DIR = join(homedir(), '.polycode');
14
+ const SECRETS_FILE = join(CONFIG_DIR, 'secrets.env');
15
+
16
+ // Provider registry. Each entry describes one LLM provider polycode can
17
+ // consume. Adding a new provider is one row: the env var name, a display
18
+ // name, a key-prefix regex, a minimum length, and the signup URL.
19
+ export const PROVIDERS = [
20
+ {
21
+ id: 'groq',
22
+ name: 'Groq',
23
+ envVar: 'GROQ_API_KEY',
24
+ prefix: /^gsk_[A-Za-z0-9]+$/,
25
+ minLength: 40,
26
+ signupUrl: 'console.groq.com',
27
+ },
28
+ {
29
+ id: 'anthropic',
30
+ name: 'Anthropic',
31
+ envVar: 'ANTHROPIC_API_KEY',
32
+ prefix: /^sk-ant-api\d{2}-[A-Za-z0-9_\-]+$/,
33
+ minLength: 50,
34
+ signupUrl: 'console.anthropic.com',
35
+ },
36
+ {
37
+ id: 'openai',
38
+ name: 'OpenAI',
39
+ envVar: 'OPENAI_API_KEY',
40
+ prefix: /^sk-(proj-)?[A-Za-z0-9_\-]{20,}$/,
41
+ minLength: 40,
42
+ signupUrl: 'platform.openai.com',
43
+ },
44
+ ];
45
+
46
+ // Auto-detect the provider from a bare key string. Returns the matching
47
+ // provider row or null if the key does not match any known shape.
48
+ export function detectProvider(key) {
49
+ const trimmed = String(key || '').trim();
50
+ if (trimmed.length === 0) return null;
51
+ for (const p of PROVIDERS) {
52
+ if (trimmed.length >= p.minLength && p.prefix.test(trimmed)) return p;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function getProviderById(id) {
58
+ return PROVIDERS.find((p) => p.id === id) || null;
59
+ }
60
+
61
+ // Read ~/.polycode/secrets.env into a plain object. Parses a dotenv subset:
62
+ // KEY=value lines, hash comments, quoted values. Missing file returns {}.
63
+ export function readSecretsFile() {
64
+ if (!existsSync(SECRETS_FILE)) return {};
65
+ try {
66
+ const content = readFileSync(SECRETS_FILE, 'utf8');
67
+ const out = {};
68
+ for (const rawLine of content.split('\n')) {
69
+ const line = rawLine.trim();
70
+ if (!line || line.startsWith('#')) continue;
71
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
72
+ if (!match) continue;
73
+ const k = match[1];
74
+ let v = match[2];
75
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
76
+ v = v.slice(1, -1);
77
+ }
78
+ out[k] = v;
79
+ }
80
+ return out;
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
85
+
86
+ // Write a single provider's key back to secrets.env. Preserves any other
87
+ // keys already in the file. Ensures the file is created with 0600 mode so
88
+ // other users on the same machine cannot read it.
89
+ export function saveProviderKey(providerId, rawKey) {
90
+ const provider = getProviderById(providerId);
91
+ if (!provider) throw new Error(`unknown provider: ${providerId}`);
92
+ const key = String(rawKey || '').trim();
93
+ if (!key) throw new Error('empty key');
94
+ if (!provider.prefix.test(key) || key.length < provider.minLength) {
95
+ throw new Error(`that does not look like a ${provider.name} key. Expected prefix and length do not match.`);
96
+ }
97
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
98
+ const existing = readSecretsFile();
99
+ existing[provider.envVar] = key;
100
+ const lines = [
101
+ '# polycode secrets. chmod 600. Never commit this file.',
102
+ ...Object.entries(existing).map(([k, v]) => `${k}=${v}`),
103
+ ];
104
+ writeFileSync(SECRETS_FILE, lines.join('\n') + '\n');
105
+ try { chmodSync(SECRETS_FILE, 0o600); } catch { /* best effort */ }
106
+ // Hot-load into process.env so the current run picks it up.
107
+ process.env[provider.envVar] = key;
108
+ return { provider, path: SECRETS_FILE };
109
+ }
110
+
111
+ // Prompt the user for a key with the terminal input masked (dots instead of
112
+ // echoed characters). Uses raw mode on process.stdin so the OS does not
113
+ // echo the key. Falls back to plain readline if raw mode is unavailable
114
+ // (non-TTY, piped input, etc.).
115
+ export async function readMaskedInput(prompt) {
116
+ const { stdin, stdout } = process;
117
+ stdout.write(prompt);
118
+
119
+ // Non-interactive fallback: read a single line from stdin without masking.
120
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
121
+ return new Promise((resolve) => {
122
+ let buf = '';
123
+ const onData = (chunk) => {
124
+ buf += chunk.toString();
125
+ const nl = buf.indexOf('\n');
126
+ if (nl !== -1) {
127
+ stdin.removeListener('data', onData);
128
+ resolve(buf.slice(0, nl).replace(/\r$/, ''));
129
+ }
130
+ };
131
+ stdin.on('data', onData);
132
+ });
133
+ }
134
+
135
+ return new Promise((resolve) => {
136
+ let buf = '';
137
+ const wasRaw = stdin.isRaw;
138
+ stdin.setRawMode(true);
139
+ stdin.resume();
140
+ stdin.setEncoding('utf8');
141
+
142
+ const cleanup = () => {
143
+ stdin.removeListener('data', onData);
144
+ try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
145
+ stdin.pause();
146
+ };
147
+
148
+ const onData = (ch) => {
149
+ // Handle paste: terminals send pasted content as one chunk.
150
+ if (ch.length > 1 && !ch.includes('\r') && !ch.includes('\n') && !ch.includes('\u0003')) {
151
+ buf += ch;
152
+ for (let i = 0; i < ch.length; i++) stdout.write('•');
153
+ return;
154
+ }
155
+ for (const c of ch) {
156
+ if (c === '\r' || c === '\n') {
157
+ stdout.write('\n');
158
+ cleanup();
159
+ resolve(buf);
160
+ return;
161
+ }
162
+ if (c === '\u0003') {
163
+ // Ctrl-C
164
+ stdout.write('\n');
165
+ cleanup();
166
+ resolve('');
167
+ return;
168
+ }
169
+ if (c === '\u007f' || c === '\b') {
170
+ if (buf.length > 0) {
171
+ buf = buf.slice(0, -1);
172
+ stdout.write('\b \b');
173
+ }
174
+ continue;
175
+ }
176
+ if (c < ' ') continue; // ignore other control chars
177
+ buf += c;
178
+ stdout.write('•');
179
+ }
180
+ };
181
+
182
+ stdin.on('data', onData);
183
+ });
184
+ }
185
+
186
+ // Top-level interactive flow: read a key, detect the provider, save, report.
187
+ // Returns { ok: true, provider, path } on success, { ok: false, reason } on
188
+ // failure. The caller renders the result to the user.
189
+ //
190
+ // readMaskedInput handles both the TTY case (raw-mode masked echo) and the
191
+ // non-TTY case (plain line read from piped stdin). The polycode REPL owns
192
+ // stdin via readPasteAwareLine in the main loop; each slash-command call
193
+ // acquires stdin via this helper, finishes its work, and cleans up so the
194
+ // main loop can re-acquire.
195
+ export async function runInteractiveKeyFlow({ providerHint, stdout } = {}) {
196
+ const out = stdout || process.stdout;
197
+ const rawKey = (await readMaskedInput('Paste your API key (or press Enter to cancel): ')).trim();
198
+ if (!rawKey) {
199
+ return { ok: false, reason: 'cancelled' };
200
+ }
201
+
202
+ let provider = null;
203
+ if (providerHint) {
204
+ provider = getProviderById(providerHint);
205
+ if (provider && !provider.prefix.test(rawKey)) {
206
+ return { ok: false, reason: `that does not look like a ${provider.name} key` };
207
+ }
208
+ }
209
+ if (!provider) provider = detectProvider(rawKey);
210
+ if (!provider) {
211
+ return {
212
+ ok: false,
213
+ reason: `could not detect provider from key prefix. Expected one of: ${PROVIDERS.map((p) => `${p.name} (${p.envVar})`).join(', ')}`,
214
+ };
215
+ }
216
+
217
+ try {
218
+ const result = saveProviderKey(provider.id, rawKey);
219
+ return { ok: true, provider: result.provider, path: result.path };
220
+ } catch (err) {
221
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
222
+ }
223
+ }