@lazyingart/agintiflow 0.17.2 → 0.17.3

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
@@ -79,7 +79,7 @@ For larger repositories, use `--profile large-codebase` or choose **Large codeba
79
79
 
80
80
  AgInTiFlow can also spend cheap DeepSeek calls on parallel scout notes before the main executor starts a complicated task. It first writes a bounded project map to `.aginti/codebase-map.json`, then runs scouts for architecture, implementation, review, research, context mapping, tests, git workflow, integration, symbol tracing, and dependency risks. A coordinator Swarm Board is injected for the main agent and saved as `artifacts/scout-blackboard.json` in the session. The executor still does the real file/shell/browser work itself. Disable with `--no-parallel-scouts` or set `--scout-count 1..10`.
81
81
 
82
- The next productive-agent roadmap is tracked in [docs/productive-agent-roadmap.md](docs/productive-agent-roadmap.md): durable codebase maps, stronger scout blackboards, long-run checkpoints, LSP/symbol tools, test triage, and release automation. Runtime choices, Docker persistence, host full-access tradeoffs, tmux sessions, and rolling-plan autonomy are documented in [docs/runtime-modes-and-autonomy.md](docs/runtime-modes-and-autonomy.md).
82
+ The next productive-agent roadmap is tracked in [docs/productive-agent-roadmap.md](docs/productive-agent-roadmap.md): durable codebase maps, stronger scout blackboards, long-run checkpoints, LSP/symbol tools, test triage, and release automation. Runtime choices, Docker persistence, host full-access tradeoffs, tmux sessions, and rolling-plan autonomy are documented in [docs/runtime-modes-and-autonomy.md](docs/runtime-modes-and-autonomy.md). The supervised self-development protocol is in [docs/self-development-supervision.md](docs/self-development-supervision.md).
83
83
 
84
84
  For current docs, install errors, package/toolchain setup, and source discovery, the agent has a guarded `web_search` tool. It returns compact search results without browser search-engine loops and respects configured domain allowlists. Disable with `--no-web-search`.
85
85
 
@@ -0,0 +1,95 @@
1
+ # Self-Development Supervision
2
+
3
+ This document defines how to let AgInTiFlow work on its own source code under Codex supervision.
4
+
5
+ ## Readiness Verdict
6
+
7
+ AgInTiFlow is ready for supervised self-development on small and medium tasks. It has the required primitives:
8
+
9
+ - Project-local sessions and web/CLI sync.
10
+ - DeepSeek flash/pro routing with mock fallback.
11
+ - Workspace file tools, deterministic `apply_patch`, and compact diffs.
12
+ - Docker workspace mode for package installs and checks.
13
+ - Host tmux tools for durable monitored sessions.
14
+ - Guardrails for secrets, npm publishing, `.git`, `node_modules`, and destructive commands.
15
+ - Full test, smoke, and pack dry-run scripts.
16
+
17
+ It is not ready for fully unsupervised release or system-level work. Codex should remain the supervisor for commits, pushes, npm publishing, high-risk host commands, and ambiguous git states.
18
+
19
+ ## Recommended Launch
20
+
21
+ Start AgInTiFlow in a separate tmux session from the source repo:
22
+
23
+ ```bash
24
+ tmux new-session -d -s agintiflow-selfdev -c /home/lachlan/ProjectsLFS/Agent/AgInTiFlow
25
+ tmux send-keys -t agintiflow-selfdev 'aginti --profile large-codebase --parallel-scouts --scout-count 5' Enter
26
+ ```
27
+
28
+ For a single supervised task:
29
+
30
+ ```bash
31
+ aginti --profile large-codebase --parallel-scouts --scout-count 5 \
32
+ "inspect this repo, implement the requested change, run focused checks, and stop before commit"
33
+ ```
34
+
35
+ Use `aginti web --port 3221` from the same folder if a browser UI is preferred. CLI and web will share `.sessions/`.
36
+
37
+ ## Supervisor Duties
38
+
39
+ Codex should supervise by:
40
+
41
+ 1. Capturing tmux output before sending input.
42
+ 2. Keeping the task scoped and testable.
43
+ 3. Reviewing diffs before commit.
44
+ 4. Running or verifying `npm test`, `npm run pack:dry-run`, and `git diff --check` when package behavior changes.
45
+ 5. Rejecting changes that weaken guardrails without explicit reasoning.
46
+ 6. Handling commit, push, and publish steps itself unless Lachlan explicitly delegates them to AgInTiFlow.
47
+
48
+ ## Safe Task Types
49
+
50
+ Good first self-development tasks:
51
+
52
+ - Add or improve docs.
53
+ - Add smoke tests around existing behavior.
54
+ - Improve prompt wording or task profiles.
55
+ - Make small CLI/web UI polish changes.
56
+ - Add capability reports or non-invasive diagnostics.
57
+
58
+ Use stronger supervision for:
59
+
60
+ - Model-client tool schemas.
61
+ - Guardrails and command policy.
62
+ - Docker runtime changes.
63
+ - Npm publishing workflow.
64
+ - Git automation.
65
+ - Session persistence and database changes.
66
+
67
+ ## Stop Conditions
68
+
69
+ AgInTiFlow should stop and ask for supervision when it sees:
70
+
71
+ - Dirty unrelated files.
72
+ - Failing tests it cannot explain.
73
+ - Git conflicts, divergence, rebase/merge choices, or reset/checkout suggestions.
74
+ - Requests to publish npm, push tags, delete files, rotate secrets, or run host sudo.
75
+ - A plan that exceeds the current step budget without a completed checkpoint.
76
+
77
+ ## Housekeeping Before Each Self-Dev Session
78
+
79
+ Run:
80
+
81
+ ```bash
82
+ git status --short
83
+ aginti doctor
84
+ aginti doctor --capabilities
85
+ npm run check
86
+ ```
87
+
88
+ For larger changes also run:
89
+
90
+ ```bash
91
+ npm test
92
+ npm run pack:dry-run
93
+ git diff --check
94
+ ```
95
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
@@ -6,10 +6,13 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { authProviderKeyHelp, authProviderKeyUrl, normalizeAuthProvider } from "../src/auth-onboarding.js";
8
8
  import { getProviderDefaults } from "../src/model-routing.js";
9
- import { providerKeyStatus, setProviderKey } from "../src/project.js";
9
+ import { maskProviderKey, providerKeyPreview, providerKeyStatus, setProviderKey } from "../src/project.js";
10
10
 
11
11
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
12
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-auth-"));
13
+ const envKeys = ["DEEPSEEK_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "QWEN_API_KEY", "GRSAI", "GRSAI_API_KEY"];
14
+ const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
15
+ for (const key of envKeys) delete process.env[key];
13
16
 
14
17
  function assert(condition, message) {
15
18
  if (!condition) throw new Error(message);
@@ -55,6 +58,8 @@ try {
55
58
  assert(normalizeAuthProvider("qwen") === "qwen", "qwen provider did not normalize");
56
59
  assert(authProviderKeyUrl("deepseek") === "https://platform.deepseek.com/api_keys", "DeepSeek key URL is missing");
57
60
  assert(authProviderKeyHelp("openai").includes("https://platform.openai.com/api-keys"), "OpenAI key help is missing");
61
+ assert(maskProviderKey("short") === "s…t (5 chars)", "short key mask was not compact");
62
+ assert(maskProviderKey("test-openai-key") === "test…-key (15 chars)", "long key mask did not preserve prefix/suffix");
58
63
  const qwenDefaults = getProviderDefaults("qwen");
59
64
  assert(qwenDefaults.provider === "qwen" && qwenDefaults.model, "qwen provider defaults are not available");
60
65
 
@@ -62,11 +67,16 @@ try {
62
67
  let status = providerKeyStatus(tempRoot);
63
68
  assert(status.qwen, "qwen key status was not detected");
64
69
  assert(status.envVars.qwen.includes("QWEN_API_KEY"), "qwen env var name was not reported");
70
+ const qwenPreview = providerKeyPreview(tempRoot, "qwen");
71
+ assert(qwenPreview.available && qwenPreview.preview === "test…-key (13 chars)", "qwen key preview was not masked correctly");
65
72
 
66
73
  await runCli(["keys", "set", "openai", "--stdin"], "test-openai-key");
67
74
  await runCli(["keys", "set", "grsai", "--stdin"], "test-grsai-key");
68
75
  status = providerKeyStatus(tempRoot);
69
76
  assert(status.openai && status.grsai && status.qwen, "stored auth keys were not detected");
77
+ const openaiPreview = providerKeyPreview(tempRoot, "openai");
78
+ assert(openaiPreview.preview === "test…-key (15 chars)", "openai key preview was not masked correctly");
79
+ assert(openaiPreview.preview !== "test-openai-key", "openai key preview leaked raw key");
70
80
 
71
81
  const keysOutput = await runCli(["keys", "status"]);
72
82
  assert(keysOutput.includes("qwen=available"), "keys status did not include qwen");
@@ -77,12 +87,24 @@ try {
77
87
  {
78
88
  ok: true,
79
89
  projectRoot: tempRoot,
80
- checks: ["normalize-auth-provider", "provider-key-links", "qwen-defaults", "qwen-key-status", "cli-key-status-redacted"],
90
+ checks: [
91
+ "normalize-auth-provider",
92
+ "provider-key-links",
93
+ "provider-key-mask",
94
+ "provider-key-preview",
95
+ "qwen-defaults",
96
+ "qwen-key-status",
97
+ "cli-key-status-redacted",
98
+ ],
81
99
  },
82
100
  null,
83
101
  2
84
102
  )
85
103
  );
86
104
  } finally {
105
+ for (const key of envKeys) {
106
+ if (originalEnv[key] === undefined) delete process.env[key];
107
+ else process.env[key] = originalEnv[key];
108
+ }
87
109
  await fs.rm(tempRoot, { recursive: true, force: true });
88
110
  }
@@ -2,7 +2,24 @@ import readline from "node:readline/promises";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
  import { Writable } from "node:stream";
5
- import { providerKeyStatus, setProviderKey } from "./project.js";
5
+ import { providerKeyPreview, providerKeyStatus, setProviderKey } from "./project.js";
6
+
7
+ const useColor = Boolean(input.isTTY && output.isTTY && process.env.AGINTIFLOW_NO_COLOR !== "1");
8
+ const ansi = {
9
+ reset: "\x1b[0m",
10
+ bold: "\x1b[1m",
11
+ dim: "\x1b[2m",
12
+ cyan: "\x1b[36m",
13
+ green: "\x1b[32m",
14
+ yellow: "\x1b[33m",
15
+ selected: "\x1b[48;5;31m\x1b[38;5;231m",
16
+ border: "\x1b[38;5;45m",
17
+ muted: "\x1b[38;5;245m",
18
+ inputBg: "\x1b[48;5;236m\x1b[38;5;231m",
19
+ clearLine: "\x1b[2K",
20
+ cursorHide: "\x1b[?25l",
21
+ cursorShow: "\x1b[?25h",
22
+ };
6
23
 
7
24
  export const MAIN_AUTH_PROVIDERS = [
8
25
  {
@@ -57,6 +74,68 @@ function providerLabel(provider = "") {
57
74
  return match?.label || provider;
58
75
  }
59
76
 
77
+ function color(value, ...codes) {
78
+ if (!useColor || codes.length === 0) return String(value);
79
+ return `${codes.join("")}${value}${ansi.reset}`;
80
+ }
81
+
82
+ function stripAnsi(value) {
83
+ return String(value || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
84
+ }
85
+
86
+ function visibleLength(value) {
87
+ return stripAnsi(value).length;
88
+ }
89
+
90
+ function terminalWidth() {
91
+ return Math.max(Number(output.columns) || 80, 50);
92
+ }
93
+
94
+ function padVisible(value, width) {
95
+ return `${value}${" ".repeat(Math.max(width - visibleLength(value), 0))}`;
96
+ }
97
+
98
+ function compactLine(value = "", limit = 92) {
99
+ const text = String(value || "").replace(/\s+/g, " ").trim();
100
+ return text.length <= limit ? text : `${text.slice(0, Math.max(limit - 1, 1))}…`;
101
+ }
102
+
103
+ function boxedLine(content, width) {
104
+ return `${color("│", ansi.border)} ${padVisible(content, width - 4)} ${color("│", ansi.border)}`;
105
+ }
106
+
107
+ function secretInputDisplay({ value, selectedPreview, placeholder }) {
108
+ if (value) return color(`${"•".repeat(Math.min(value.length, 24))} (${value.length} chars)`, ansi.inputBg);
109
+ if (selectedPreview) return `${color(` ${selectedPreview} `, ansi.selected, ansi.bold)} ${color("selected", ansi.yellow)}`;
110
+ return color(placeholder || "paste key here", ansi.dim);
111
+ }
112
+
113
+ function renderSecretBox({ title, helpText, statusText, currentPreview, value, selected, actionText }) {
114
+ const width = Math.min(Math.max(terminalWidth() - 2, 58), 96);
115
+ const inner = width - 4;
116
+ const selectedPreview = selected && currentPreview ? currentPreview : "";
117
+ const lines = [
118
+ `${color("╭", ansi.border)}${color("─", ansi.border).repeat(width - 2)}${color("╮", ansi.border)}`,
119
+ boxedLine(color(compactLine(title, inner), ansi.bold, ansi.cyan), width),
120
+ helpText ? boxedLine(`${color("key", ansi.muted)} ${compactLine(helpText, inner - 6)}`, width) : "",
121
+ statusText ? boxedLine(`${color("status", ansi.muted)} ${compactLine(statusText, inner - 8)}`, width) : "",
122
+ boxedLine(`${color("input", ansi.muted)} ${secretInputDisplay({ value, selectedPreview, placeholder: "hidden input" })}`, width),
123
+ boxedLine(color(compactLine(actionText, inner), ansi.dim), width),
124
+ `${color("╰", ansi.border)}${color("─", ansi.border).repeat(width - 2)}${color("╯", ansi.border)}`,
125
+ ].filter(Boolean);
126
+ return lines.join("\n");
127
+ }
128
+
129
+ function clearRenderedLines(count) {
130
+ if (!count) return;
131
+ output.write(`\x1b[${count - 1}A`);
132
+ for (let index = 0; index < count; index += 1) {
133
+ output.write(`\r${ansi.clearLine}`);
134
+ if (index < count - 1) output.write("\x1b[1B");
135
+ }
136
+ output.write(`\x1b[${count - 1}A\r`);
137
+ }
138
+
60
139
  export function authProviderKeyUrl(provider = "") {
61
140
  const normalized = normalizeAuthProvider(provider, "");
62
141
  const match = [...MAIN_AUTH_PROVIDERS, AUXILIARY_AUTH_PROVIDER].find((item) => item.id === normalized);
@@ -83,7 +162,7 @@ class MutedWritable extends Writable {
83
162
  }
84
163
  }
85
164
 
86
- export async function promptSecret(promptText, { allowEscape = true } = {}) {
165
+ export async function promptSecret(promptText, { allowEscape = true, box = null } = {}) {
87
166
  if (!input.isTTY || !output.isTTY) return { value: "", skipped: true };
88
167
 
89
168
  if (typeof input.setRawMode === "function") {
@@ -91,21 +170,38 @@ export async function promptSecret(promptText, { allowEscape = true } = {}) {
91
170
  emitKeypressEvents(input);
92
171
  const wasRaw = Boolean(input.isRaw);
93
172
  let value = "";
173
+ let selectedExisting = Boolean(box?.currentPreview);
174
+ let renderedLines = 0;
175
+
176
+ const render = () => {
177
+ if (!box) return;
178
+ clearRenderedLines(renderedLines);
179
+ const text = renderSecretBox({
180
+ ...box,
181
+ value,
182
+ selected: selectedExisting,
183
+ });
184
+ renderedLines = text.split("\n").length;
185
+ output.write(`${ansi.cursorHide}${text}`);
186
+ };
94
187
 
95
188
  const cleanup = () => {
96
189
  input.off("keypress", handler);
97
190
  if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
98
191
  input.pause();
192
+ output.write(ansi.cursorShow);
99
193
  };
100
194
 
101
195
  const finish = (result) => {
196
+ if (box) clearRenderedLines(renderedLines);
102
197
  cleanup();
103
- output.write("\n");
198
+ if (!box) output.write("\n");
104
199
  resolve(result);
105
200
  };
106
201
 
107
202
  const handler = (str = "", key = {}) => {
108
203
  if (key.ctrl && key.name === "c") {
204
+ if (box) clearRenderedLines(renderedLines);
109
205
  cleanup();
110
206
  reject(Object.assign(new Error("Interrupted by ctrl-c."), { name: "AbortError", code: "ABORT_ERR" }));
111
207
  return;
@@ -119,14 +215,34 @@ export async function promptSecret(promptText, { allowEscape = true } = {}) {
119
215
  return;
120
216
  }
121
217
  if (key.name === "backspace" || key.name === "delete") {
122
- value = value.slice(0, -1);
218
+ if (selectedExisting) {
219
+ selectedExisting = false;
220
+ value = "";
221
+ } else {
222
+ value = value.slice(0, -1);
223
+ }
224
+ render();
225
+ return;
226
+ }
227
+ if (key.ctrl && key.name === "u") {
228
+ selectedExisting = false;
229
+ value = "";
230
+ render();
123
231
  return;
124
232
  }
125
233
  if (key.ctrl || key.meta || key.sequence?.startsWith("\x1b")) return;
126
- if (str) value += str.replace(/\r|\n/g, "");
234
+ if (str) {
235
+ if (selectedExisting) {
236
+ selectedExisting = false;
237
+ value = "";
238
+ }
239
+ value += str.replace(/\r|\n/g, "");
240
+ render();
241
+ }
127
242
  };
128
243
 
129
- output.write(promptText);
244
+ if (box) render();
245
+ else output.write(promptText);
130
246
  input.resume();
131
247
  input.setRawMode(true);
132
248
  input.on("keypress", handler);
@@ -313,10 +429,23 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
313
429
  const skipped = [];
314
430
 
315
431
  if (mainProvider) {
316
- const current = status[mainProvider] ? "currently available; paste a new key to replace, or Esc to keep existing" : "missing";
432
+ const preview = providerKeyPreview(projectRoot, mainProvider);
433
+ const current = preview.available ? `available: ${preview.preview}` : "missing";
317
434
  const keyHelp = authProviderKeyHelp(mainProvider);
318
- const prompt = `${keyHelp ? `${keyHelp}\n` : ""}${providerLabel(mainProvider)} main API key (${current}) [hidden]: `;
319
- const secret = await promptSecret(prompt, { allowEscape: true });
435
+ const title = `${providerLabel(mainProvider)} main API key`;
436
+ const prompt = `${keyHelp ? `${keyHelp}\n` : ""}${title} (${current}) [hidden]: `;
437
+ const secret = await promptSecret(prompt, {
438
+ allowEscape: true,
439
+ box: {
440
+ title,
441
+ helpText: keyHelp || "Paste the provider API key.",
442
+ statusText: preview.available ? `Existing ${preview.keyName}: ${preview.preview}` : `Missing ${preview.keyName || "provider key"}`,
443
+ currentPreview: preview.preview,
444
+ actionText: preview.available
445
+ ? "Existing key is selected. Type to replace, Enter/Esc to keep existing."
446
+ : "Paste key and Enter to save, or Esc/Enter to skip.",
447
+ },
448
+ });
320
449
  if (secret.value) {
321
450
  const result = await setProviderKey(projectRoot, mainProvider, secret.value);
322
451
  saved.push(result);
@@ -328,10 +457,18 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
328
457
  }
329
458
 
330
459
  if (options.includeAuxiliary !== false && directProvider !== "grsai") {
331
- const auxStatus = providerKeyStatus(projectRoot);
332
- const current = auxStatus.grsai ? "currently available; paste a new key to replace, or Esc to skip" : "optional; paste key or Esc to skip";
333
- const secret = await promptSecret(`${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key (${current}) [hidden]: `, {
460
+ const preview = providerKeyPreview(projectRoot, "grsai");
461
+ const current = preview.available ? `available: ${preview.preview}` : "optional";
462
+ const title = `${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key`;
463
+ const secret = await promptSecret(`${title} (${current}) [hidden]: `, {
334
464
  allowEscape: true,
465
+ box: {
466
+ title,
467
+ helpText: "Optional image generation key for GRS AI / Nano Banana.",
468
+ statusText: preview.available ? `Existing ${preview.keyName}: ${preview.preview}` : "Optional; no auxiliary key saved.",
469
+ currentPreview: preview.preview,
470
+ actionText: preview.available ? "Existing key is selected. Type to replace, Enter/Esc to keep existing." : "Paste key and Enter to save, or Esc/Enter to skip.",
471
+ },
335
472
  });
336
473
  if (secret.value) {
337
474
  const result = await setProviderKey(projectRoot, "grsai", secret.value);
@@ -340,8 +477,17 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
340
477
  skipped.push({ provider: "grsai", reason: "skipped" });
341
478
  }
342
479
  } else if (directProvider === "grsai") {
343
- const secret = await promptSecret(`${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key [hidden]: `, {
480
+ const preview = providerKeyPreview(projectRoot, "grsai");
481
+ const title = `${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key`;
482
+ const secret = await promptSecret(`${title} [hidden]: `, {
344
483
  allowEscape: true,
484
+ box: {
485
+ title,
486
+ helpText: "Optional image generation key for GRS AI / Nano Banana.",
487
+ statusText: preview.available ? `Existing ${preview.keyName}: ${preview.preview}` : "Missing auxiliary key.",
488
+ currentPreview: preview.preview,
489
+ actionText: preview.available ? "Existing key is selected. Type to replace, Enter/Esc to keep existing." : "Paste key and Enter to save, or Esc/Enter to skip.",
490
+ },
345
491
  });
346
492
  if (secret.value) saved.push(await setProviderKey(projectRoot, "grsai", secret.value));
347
493
  else skipped.push({ provider: "grsai", reason: "skipped" });
package/src/project.js CHANGED
@@ -16,10 +16,18 @@ const LOCAL_ENV_KEYS = new Set([
16
16
  "DEEPSEEK_FAST_MODEL",
17
17
  "DEEPSEEK_PRO_MODEL",
18
18
  "OPENAI_DEFAULT_MODEL",
19
+ "QWEN_API_KEY",
19
20
  "GRSAI",
20
21
  "GRSAI_API_KEY",
21
22
  ]);
22
23
 
24
+ const PROVIDER_KEY_CANDIDATES = {
25
+ openai: ["OPENAI_API_KEY", "LLM_API_KEY"],
26
+ deepseek: ["DEEPSEEK_API_KEY", "LLM_API_KEY"],
27
+ qwen: ["QWEN_API_KEY"],
28
+ grsai: ["GRSAI", "GRSAI_API_KEY"],
29
+ };
30
+
23
31
  export function resolveProjectRoot(input = process.cwd()) {
24
32
  return path.resolve(input || process.cwd());
25
33
  }
@@ -267,6 +275,45 @@ export function providerKeyStatus(projectRoot = process.cwd()) {
267
275
  };
268
276
  }
269
277
 
278
+ export function maskProviderKey(value = "") {
279
+ const text = String(value || "").trim();
280
+ if (!text) return "";
281
+ if (text.length <= 8) return `${text.slice(0, 1)}…${text.slice(-1)} (${text.length} chars)`;
282
+ return `${text.slice(0, 4)}…${text.slice(-4)} (${text.length} chars)`;
283
+ }
284
+
285
+ export function providerKeyPreview(projectRoot = process.cwd(), provider = "") {
286
+ loadProjectEnv(projectRoot);
287
+ const normalized = String(provider || "").trim().toLowerCase();
288
+ const aliases = {
289
+ auxiliary: "grsai",
290
+ auxilliary: "grsai",
291
+ image: "grsai",
292
+ imagegen: "grsai",
293
+ };
294
+ const canonical = aliases[normalized] || normalized;
295
+ const keys = PROVIDER_KEY_CANDIDATES[canonical] || [];
296
+ for (const keyName of keys) {
297
+ const value = process.env[keyName];
298
+ if (value) {
299
+ return {
300
+ available: true,
301
+ provider: canonical,
302
+ keyName,
303
+ preview: maskProviderKey(value),
304
+ length: String(value).trim().length,
305
+ };
306
+ }
307
+ }
308
+ return {
309
+ available: false,
310
+ provider: canonical,
311
+ keyName: keys[0] || "",
312
+ preview: "",
313
+ length: 0,
314
+ };
315
+ }
316
+
270
317
  export async function setProviderKey(projectRoot, provider, value) {
271
318
  const normalizedProvider = String(provider || "").toLowerCase();
272
319
  const aliases = {