@polylogicai/polycode 1.1.3 → 1.1.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.
@@ -0,0 +1,232 @@
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
+ // If `rl` is passed (a readline.Interface from readline/promises), we use
191
+ // rl.question for the prompt instead of attaching a raw stdin listener. That
192
+ // path matters for the slash-command case inside the REPL where readline
193
+ // already owns stdin; using rl.question avoids a race where readline eats
194
+ // the key line before our masked reader can see it.
195
+ export async function runInteractiveKeyFlow({ providerHint, stdout, rl } = {}) {
196
+ const out = stdout || process.stdout;
197
+ let rawKey;
198
+ if (rl && typeof rl.question === 'function') {
199
+ // Inside the REPL: read through the active readline instance.
200
+ // Note: this path does not mask echo in the terminal. That is a
201
+ // deliberate trade-off for reliable input under the REPL. For a
202
+ // fully-masked prompt, use `polycode login` from the shell instead.
203
+ rawKey = (await rl.question('Paste your API key (or press Enter to cancel): ')).trim();
204
+ } else {
205
+ rawKey = (await readMaskedInput('Paste your API key (or press Enter to cancel): ')).trim();
206
+ }
207
+ if (!rawKey) {
208
+ return { ok: false, reason: 'cancelled' };
209
+ }
210
+
211
+ let provider = null;
212
+ if (providerHint) {
213
+ provider = getProviderById(providerHint);
214
+ if (provider && !provider.prefix.test(rawKey)) {
215
+ return { ok: false, reason: `that does not look like a ${provider.name} key` };
216
+ }
217
+ }
218
+ if (!provider) provider = detectProvider(rawKey);
219
+ if (!provider) {
220
+ return {
221
+ ok: false,
222
+ reason: `could not detect provider from key prefix. Expected one of: ${PROVIDERS.map((p) => `${p.name} (${p.envVar})`).join(', ')}`,
223
+ };
224
+ }
225
+
226
+ try {
227
+ const result = saveProviderKey(provider.id, rawKey);
228
+ return { ok: true, provider: result.provider, path: result.path };
229
+ } catch (err) {
230
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ }
@@ -0,0 +1,208 @@
1
+ // lib/paste-aware-prompt.mjs
2
+ // A custom line reader that implements bracketed paste mode for the polycode
3
+ // REPL. Standard readline cannot tell a one-character typed key from a
4
+ // multi-line paste, so pasting a 500-line file floods the terminal and
5
+ // floods the display with echoed content. Modern terminals support
6
+ // bracketed paste mode: when enabled, the terminal wraps pasted content in
7
+ // ESC[200~ ... ESC[201~ so the host program can detect the paste as a
8
+ // single event.
9
+ //
10
+ // This reader enables bracketed paste on startup, reads stdin in raw mode,
11
+ // collapses each paste to a placeholder like [Pasted #1 (20 lines)] in the
12
+ // display, and returns both the displayed line and the full content with
13
+ // placeholders expanded for the agent to read.
14
+
15
+ import { stdin, stdout } from 'node:process';
16
+
17
+ const ESC = '\u001b';
18
+ const PASTE_START = `${ESC}[200~`;
19
+ const PASTE_END = `${ESC}[201~`;
20
+
21
+ const BRACKETED_PASTE_ON = `${ESC}[?2004h`;
22
+ const BRACKETED_PASTE_OFF = `${ESC}[?2004l`;
23
+
24
+ // Ordinal counter for paste placeholders within the lifetime of the process.
25
+ // Each successful paste increments it so the user can reference pastes by
26
+ // number ("expand pasted #3") in future features.
27
+ let pasteOrdinal = 0;
28
+
29
+ export function enableBracketedPaste() {
30
+ if (stdout.isTTY) stdout.write(BRACKETED_PASTE_ON);
31
+ }
32
+
33
+ export function disableBracketedPaste() {
34
+ if (stdout.isTTY) stdout.write(BRACKETED_PASTE_OFF);
35
+ }
36
+
37
+ // Read one line from stdin with paste awareness. Returns a promise that
38
+ // resolves to { displayed, content, pastes }:
39
+ // - displayed: the string the user sees in the terminal (with placeholders)
40
+ // - content: the string to send to the agent (with full paste text)
41
+ // - pastes: an array of { ordinal, lines, bytes, text } for logging/telemetry
42
+ //
43
+ // On non-TTY stdin (piped input, tests), this reader falls back to a plain
44
+ // line read with no paste handling, since bracketed paste requires a real
45
+ // terminal.
46
+ export function readPasteAwareLine(prompt) {
47
+ return new Promise((resolve, reject) => {
48
+ stdout.write(prompt);
49
+
50
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
51
+ let buf = '';
52
+ const onData = (chunk) => {
53
+ buf += chunk.toString('utf8');
54
+ const nlIdx = buf.indexOf('\n');
55
+ if (nlIdx !== -1) {
56
+ stdin.removeListener('data', onData);
57
+ const line = buf.slice(0, nlIdx).replace(/\r$/, '');
58
+ resolve({ displayed: line, content: line, pastes: [] });
59
+ }
60
+ };
61
+ stdin.on('data', onData);
62
+ return;
63
+ }
64
+
65
+ const wasRaw = stdin.isRaw;
66
+ stdin.setRawMode(true);
67
+ stdin.resume();
68
+ stdin.setEncoding('utf8');
69
+
70
+ let stream = '';
71
+ const pastes = [];
72
+ const segments = [];
73
+ let currentText = '';
74
+ let visibleLength = 0;
75
+
76
+ const cleanup = () => {
77
+ stdin.removeListener('data', onData);
78
+ try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
79
+ stdin.pause();
80
+ };
81
+
82
+ const flushCurrent = () => {
83
+ if (currentText.length > 0) {
84
+ segments.push({ kind: 'text', text: currentText });
85
+ currentText = '';
86
+ }
87
+ };
88
+
89
+ const finish = () => {
90
+ flushCurrent();
91
+ cleanup();
92
+ stdout.write('\n');
93
+
94
+ const displayed = segments
95
+ .map((s) =>
96
+ s.kind === 'text'
97
+ ? s.text
98
+ : `[Pasted #${s.ordinal} (${s.lines} lines)]`
99
+ )
100
+ .join('');
101
+ const content = segments.map((s) => (s.kind === 'text' ? s.text : s.text)).join('');
102
+ resolve({ displayed, content, pastes });
103
+ };
104
+
105
+ const onData = (chunk) => {
106
+ try {
107
+ stream += chunk;
108
+ // Drain the stream one token at a time.
109
+ while (stream.length > 0) {
110
+ // Paste start?
111
+ if (stream.startsWith(PASTE_START)) {
112
+ const endIdx = stream.indexOf(PASTE_END, PASTE_START.length);
113
+ if (endIdx === -1) {
114
+ // Waiting for the rest of the paste; don't consume anything.
115
+ return;
116
+ }
117
+ const pasted = stream.slice(PASTE_START.length, endIdx);
118
+ stream = stream.slice(endIdx + PASTE_END.length);
119
+ flushCurrent();
120
+ pasteOrdinal += 1;
121
+ const ordinal = pasteOrdinal;
122
+ const lines = pasted.split('\n').length;
123
+ const bytes = Buffer.byteLength(pasted, 'utf8');
124
+ const entry = { ordinal, lines, bytes, text: pasted };
125
+ pastes.push(entry);
126
+ segments.push({ kind: 'paste', ordinal, lines, bytes, text: pasted });
127
+ const placeholder = `[Pasted #${ordinal} (${lines} lines)]`;
128
+ stdout.write(`\u001b[2m${placeholder}\u001b[0m`);
129
+ visibleLength += placeholder.length;
130
+ continue;
131
+ }
132
+ // Single char path
133
+ const ch = stream[0];
134
+ stream = stream.slice(1);
135
+
136
+ if (ch === '\r' || ch === '\n') {
137
+ finish();
138
+ return;
139
+ }
140
+ if (ch === '\u0003') {
141
+ // Ctrl-C
142
+ cleanup();
143
+ stdout.write('\n');
144
+ reject(new Error('cancelled'));
145
+ return;
146
+ }
147
+ if (ch === '\u0004') {
148
+ // Ctrl-D on empty line = exit; on non-empty = ignored
149
+ if (currentText.length === 0 && segments.length === 0) {
150
+ finish();
151
+ return;
152
+ }
153
+ continue;
154
+ }
155
+ if (ch === '\u007f' || ch === '\b') {
156
+ // Backspace. If the last segment is a paste, remove the whole
157
+ // paste (one deletion = one paste). Otherwise remove one char.
158
+ if (currentText.length > 0) {
159
+ currentText = currentText.slice(0, -1);
160
+ stdout.write('\b \b');
161
+ visibleLength = Math.max(0, visibleLength - 1);
162
+ } else if (segments.length > 0) {
163
+ const last = segments.pop();
164
+ if (last.kind === 'paste') {
165
+ const width = `[Pasted #${last.ordinal} (${last.lines} lines)]`.length;
166
+ for (let i = 0; i < width; i++) stdout.write('\b \b');
167
+ visibleLength = Math.max(0, visibleLength - width);
168
+ // Also remove from the pastes[] index so logs match.
169
+ const pi = pastes.findIndex((p) => p.ordinal === last.ordinal);
170
+ if (pi !== -1) pastes.splice(pi, 1);
171
+ } else {
172
+ // Put it back and delete one char.
173
+ segments.push(last);
174
+ if (last.text.length > 0) {
175
+ last.text = last.text.slice(0, -1);
176
+ stdout.write('\b \b');
177
+ visibleLength = Math.max(0, visibleLength - 1);
178
+ }
179
+ }
180
+ }
181
+ continue;
182
+ }
183
+ if (ch === ESC) {
184
+ // Unrecognized escape sequence. Consume the minimum so we don't
185
+ // block; terminal-specific sequences (arrow keys, etc.) aren't
186
+ // supported in this reader, they get swallowed silently.
187
+ if (stream.length >= 2 && stream[0] === '[') {
188
+ stream = stream.slice(2);
189
+ }
190
+ continue;
191
+ }
192
+ if (ch < ' ' && ch !== '\t') {
193
+ // Other control char: ignore.
194
+ continue;
195
+ }
196
+ currentText += ch;
197
+ stdout.write(ch);
198
+ visibleLength += 1;
199
+ }
200
+ } catch (err) {
201
+ cleanup();
202
+ reject(err);
203
+ }
204
+ };
205
+
206
+ stdin.on('data', onData);
207
+ });
208
+ }
@@ -0,0 +1,100 @@
1
+ // lib/polycode-hosted-client.mjs
2
+ // Hosted inference client for users who have not provided a GROQ_API_KEY.
3
+ // Posts chat-completions requests to the Polylogic proxy at
4
+ // polylogicai.com/api/polycode/inference, which forwards them to Groq using
5
+ // a server-side key. Rate limited to 60 turns per install per hour. Power
6
+ // users can skip the proxy by setting GROQ_API_KEY, in which case the agent
7
+ // talks to Groq directly.
8
+ //
9
+ // The returned object mimics the subset of the groq-sdk surface that the
10
+ // agentic loop uses (chat.completions.create), so the call sites do not need
11
+ // to know which backend is in play.
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ const DEFAULT_PROXY_URL = 'https://polylogicai.com/api/polycode/inference';
19
+ const CONFIG_DIR = join(homedir(), '.polycode');
20
+ const INSTALL_ID_PATH = join(CONFIG_DIR, 'install_id');
21
+
22
+ // Produce a stable per-install identifier. Created on first run, persisted
23
+ // to ~/.polycode/install_id so subsequent runs reuse the same ID for rate
24
+ // limiting. The file contains a bare UUID with no PII.
25
+ export function getOrCreateInstallId() {
26
+ try {
27
+ if (existsSync(INSTALL_ID_PATH)) {
28
+ const existing = readFileSync(INSTALL_ID_PATH, 'utf8').trim();
29
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(existing)) return existing;
30
+ }
31
+ } catch {
32
+ // fall through and regenerate
33
+ }
34
+ if (!existsSync(CONFIG_DIR)) {
35
+ mkdirSync(CONFIG_DIR, { recursive: true });
36
+ }
37
+ const fresh = randomUUID();
38
+ try {
39
+ writeFileSync(INSTALL_ID_PATH, fresh + '\n', { mode: 0o600 });
40
+ } catch {
41
+ // non-fatal; rate limiting will fall back to per-IP
42
+ }
43
+ return fresh;
44
+ }
45
+
46
+ // Minimal Groq-SDK-shaped client that posts every request to the Polylogic
47
+ // proxy. The shape matches what lib/agentic.mjs calls: an object with a
48
+ // `chat.completions.create(payload)` method returning an OpenAI-compatible
49
+ // chat completion object.
50
+ export function createHostedClient({ version, proxyUrl } = {}) {
51
+ const url = proxyUrl || process.env.POLYCODE_PROXY_URL || DEFAULT_PROXY_URL;
52
+ const installId = getOrCreateInstallId();
53
+ const headers = {
54
+ 'Content-Type': 'application/json',
55
+ 'X-Polycode-Install-ID': installId,
56
+ 'X-Polycode-Version': version || 'unknown',
57
+ };
58
+
59
+ async function createCompletion(payload) {
60
+ let res;
61
+ try {
62
+ res = await fetch(url, {
63
+ method: 'POST',
64
+ headers,
65
+ body: JSON.stringify(payload),
66
+ });
67
+ } catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ const wrapped = new Error(
70
+ `polycode hosted inference unreachable at ${url}: ${msg}. Set GROQ_API_KEY to a free key from console.groq.com to bypass the proxy.`
71
+ );
72
+ wrapped.cause = err;
73
+ throw wrapped;
74
+ }
75
+
76
+ const text = await res.text();
77
+ if (!res.ok) {
78
+ let parsed;
79
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
80
+ const serverMsg = parsed?.error || text || `HTTP ${res.status}`;
81
+ throw new Error(`polycode hosted inference error (${res.status}): ${serverMsg}`);
82
+ }
83
+
84
+ try {
85
+ return JSON.parse(text);
86
+ } catch (err) {
87
+ throw new Error(`polycode hosted inference returned non-JSON body: ${text.slice(0, 200)}`);
88
+ }
89
+ }
90
+
91
+ return {
92
+ chat: {
93
+ completions: {
94
+ create: createCompletion,
95
+ },
96
+ },
97
+ _proxyUrl: url,
98
+ _installId: installId,
99
+ };
100
+ }
package/lib/repl-ui.mjs CHANGED
@@ -139,7 +139,9 @@ export function createRenderer(stdout, opts = {}) {
139
139
  const out = compactToolResultLine(ev.name, ev.result);
140
140
  if (out) line(out);
141
141
  } else if (ev.phase === 'scrub_blocked') {
142
- line(`${C.red}refusing to send tool output to the model: contains a recognized secret pattern${C.reset}`);
142
+ // Suppressed: a follow-up `act/message` event renders the friendlier
143
+ // guidance that tells the user to type /key instead. We hide the raw
144
+ // scrub event to avoid a scary red line before the guidance.
143
145
  } else if (ev.phase === 'error') {
144
146
  line(`${C.red}error: ${ev.message}${C.reset}`);
145
147
  }
@@ -5,10 +5,12 @@
5
5
 
6
6
  import { C } from './repl-ui.mjs';
7
7
  import { rotateIntent } from './intent.mjs';
8
+ import { runInteractiveKeyFlow, PROVIDERS, getProviderById } from './key-store.mjs';
8
9
 
9
10
  export const SLASH_HELP = `
10
11
  ${C.bold}polycode commands${C.reset}
11
12
  /help Show this help
13
+ /key [provider] Save an API key (Groq, Anthropic, OpenAI). Input is masked.
12
14
  /log Show session log path, row count, and last hash
13
15
  /replay <n> Print the last N session log rows
14
16
  /verify Verify session log SHA-256 chain integrity
@@ -18,15 +20,44 @@ ${C.bold}polycode commands${C.reset}
18
20
  /exit, /quit Leave polycode
19
21
  `;
20
22
 
21
- export async function dispatchSlash(line, { canon, state, stdout }) {
23
+ export async function dispatchSlash(line, { canon, state, stdout, loop, rl }) {
22
24
  const [cmd, ...rest] = line.slice(1).split(/\s+/);
23
- const args = rest.join(' ');
25
+ const args = rest.join(' ').trim();
24
26
 
25
27
  switch (cmd) {
26
28
  case 'help':
27
29
  stdout.write(SLASH_HELP + '\n');
28
30
  return { continue: true };
29
31
 
32
+ case 'key':
33
+ case 'login': {
34
+ const providerHint = args || null;
35
+ if (providerHint && !getProviderById(providerHint)) {
36
+ stdout.write(`${C.red}unknown provider${C.reset}: ${providerHint}. Known: ${PROVIDERS.map((p) => p.id).join(', ')}\n`);
37
+ return { continue: true };
38
+ }
39
+ 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`);
40
+ const result = await runInteractiveKeyFlow({ providerHint, stdout, rl });
41
+ if (!result.ok) {
42
+ if (result.reason === 'cancelled') {
43
+ stdout.write(`${C.dim}cancelled${C.reset}\n`);
44
+ } else {
45
+ stdout.write(`${C.red}error${C.reset}: ${result.reason}\n`);
46
+ }
47
+ return { continue: true };
48
+ }
49
+ stdout.write(`${C.amber}saved${C.reset}: ${result.provider.name} key (${result.provider.envVar})\n`);
50
+ // Hot-swap the live loop: if the saved key is for the current provider
51
+ // (Groq), flip out of hosted mode so the next turn goes direct to the
52
+ // provider. Other providers just land in env for next compile.
53
+ if (loop && result.provider.id === 'groq') {
54
+ loop.apiKey = process.env[result.provider.envVar];
55
+ loop.hostedMode = false;
56
+ stdout.write(`${C.dim}switched this session from hosted tier to direct Groq. Unlimited use.${C.reset}\n`);
57
+ }
58
+ return { continue: true };
59
+ }
60
+
30
61
  case 'log':
31
62
  case 'history':
32
63
  case 'canon': {