@lazyingart/agintiflow 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,13 +46,15 @@ aginti --list-profiles
46
46
  aginti --sandbox-status
47
47
  ```
48
48
 
49
- On first interactive use, if no DeepSeek key is detected, `aginti` asks you to paste it and saves it to the project-local ignored file `.aginti/.env` with `0600` permissions. You can also set it explicitly:
49
+ On first interactive use, if no main model key is detected, `aginti` opens an auth wizard. Use Up/Down to choose DeepSeek, OpenAI, or Qwen, paste the key, and press Enter to save it to the project-local ignored file `.aginti/.env` with `0600` permissions. The wizard then offers the optional auxiliary image key; press Esc to skip. You can rerun it even when keys already exist:
50
50
 
51
51
  ```bash
52
- aginti login deepseek
53
- # inside chat, use /login or /auth
52
+ aginti auth
53
+ aginti auth openai
54
+ # inside chat, use /login or /auth for the same wizard
54
55
  # or non-interactively:
55
56
  printf '%s' "$DEEPSEEK_API_KEY" | aginti keys set deepseek --stdin
57
+ printf '%s' "$QWEN_API_KEY" | aginti keys set qwen --stdin
56
58
 
57
59
  # optional image-generation auxiliary skill:
58
60
  aginti login grsai
@@ -67,7 +69,7 @@ aginti
67
69
  aginti chat
68
70
  ```
69
71
 
70
- Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/instructions` to inspect `AGINTI.md`, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. `Ctrl+J` inserts a new line in the colored input panel, Enter sends, arrow keys move through wrapped multiline input, and `Ctrl+A`/`Ctrl+E` jump to the current line start/end. During an active run, Enter sends the draft as an ASAP pipe message (`→`) and Tab queues it for after the run (`↳`); ASAP messages are consumed first, Alt+Up edits the last piped message, and Shift+Left edits the last after-finish queued message. The input panel always shows the current `cwd` footer and a single live status row, so long goals and tool updates are compacted instead of flooding the transcript. Assistant responses start on a fresh line after the `aginti>` header with a colored response gutter and render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, tables, and red/green patch diff lines. Resuming a session prints the full saved chat history with wrapped messages before the prompt. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
72
+ Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/instructions` to inspect `AGINTI.md`, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. `Ctrl+J` inserts a new line in the colored input panel, Enter sends, arrow keys move through wrapped multiline input, and `Ctrl+A`/`Ctrl+E` jump to the current line start/end. During an active run, Enter sends the draft as an ASAP pipe message (`→`) and Tab queues it for after the run (`↳`); ASAP messages are consumed before after-finish queued prompts, Alt+Up edits the last piped message, and Shift+Left edits the last after-finish queued message. Idle Esc is ignored so it does not disturb the input panel; during a run, Esc waits when `→` messages are pending and otherwise stops the run cleanly. Ctrl+C always stops and prints the resume command. The input panel always shows the current `cwd` footer and a single live status row, so long goals and tool updates are compacted instead of flooding the transcript. Assistant responses start on a fresh line after the `aginti>` header with a colored response gutter and render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, tables, and red/green patch diff lines. Resuming a session prints the full saved chat history with wrapped messages before the prompt.
71
73
 
72
74
  `aginti init` creates `AGINTI.md` at the project root. This is the editable project-instruction file for both CLI and web runs, similar to `AGENTS.md` or project memory in other agents. Keep durable preferences, commands, and constraints there, but never secrets. You can edit it manually or ask in chat, for example: `update AGINTI.md to remember that this project uses pytest and npm run check`.
73
75
 
@@ -269,7 +271,7 @@ Defaults:
269
271
  | `smart` | DeepSeek | Fast for normal tasks, pro for complex tasks | `AGENT_ROUTING_MODE=smart` |
270
272
  | `fast` | DeepSeek | `deepseek-v4-flash` | `DEEPSEEK_FAST_MODEL` |
271
273
  | `complex` | DeepSeek | `deepseek-v4-pro` | `DEEPSEEK_PRO_MODEL` |
272
- | `manual` | DeepSeek/OpenAI | user supplied | `AGENT_PROVIDER`, `LLM_MODEL` |
274
+ | `manual` | DeepSeek/OpenAI/Qwen | user supplied | `AGENT_PROVIDER`, `LLM_MODEL` |
273
275
 
274
276
  Provider credentials:
275
277
 
@@ -277,6 +279,7 @@ Provider credentials:
277
279
  | --- | --- | --- |
278
280
  | OpenAI | `OPENAI_API_KEY` | `https://api.openai.com/v1` |
279
281
  | DeepSeek | `DEEPSEEK_API_KEY` | `https://api.deepseek.com/v1` |
282
+ | Qwen | `QWEN_API_KEY` | `QWEN_BASE_URL` or DashScope compatible mode |
280
283
 
281
284
  Project-local credentials can be stored without committing secrets:
282
285
 
@@ -9,11 +9,11 @@ AgInTiFlow keeps CLI and web runs equivalent by using the project folder as the
9
9
 
10
10
  When a run is active, the web chat and `aginti queue <session-id> "..."` append messages to the inbox instead of trying to mutate the running process directly. The web API exposes `GET /api/sessions/:id/inbox`, `POST /api/sessions/:id/inbox`, `PATCH /api/sessions/:id/inbox/:itemId`, and `DELETE /api/sessions/:id/inbox/:itemId` so browser users can inspect, edit, or remove pending pipe messages before the runner consumes them. The runner drains the inbox at safe boundaries: before each model step and after tool execution. This mirrors the event-queue style used by mature agent UIs while keeping the backend decoupled from any specific frontend.
11
11
 
12
- The interactive CLI keeps the input panel visible while a run is working. Enter sends the current draft as an ASAP pipe message and displays it as `→`; the runner drains those messages before normal inbox items. Tab stores the draft as an after-finish queue item and displays it as `↳`; those prompts run only after the current run completes. Alt+Up moves the last pending `→` message back into the editor, and Shift+Left moves the last pending `↳` message back into the editor. The current command cwd is rendered below the input panel in both idle and running states.
12
+ The interactive CLI keeps the input panel visible while a run is working. Enter sends the current draft as an ASAP pipe message and displays it as `→`; the runner drains those messages before normal inbox items and before after-finish queued prompts. Tab stores the draft as an after-finish queue item and displays it as `↳`; those prompts run only after the current run completes. Alt+Up moves the last pending `→` message back into the editor, and Shift+Left moves the last pending `↳` message back into the editor. Idle Esc is ignored so it does not redraw the prompt into the transcript. During a run, Esc waits when `→` pipe messages are still pending and stops the run only when no ASAP pipe message is pending; Ctrl+C always stops. The current command cwd is rendered below the input panel in both idle and running states.
13
13
 
14
14
  The web UI uses a related but browser-appropriate pattern. Enter sends and Shift+Enter adds a newline. `Pipe to run` writes an ASAP inbox item shared with CLI. `Queue after finish` keeps a browser-local next prompt and starts it after the current web-owned run finishes. Both lanes render in a pending panel with Edit and Remove buttons instead of terminal-only keybindings.
15
15
 
16
- Runs can be stopped without corrupting session state. The CLI listens for Esc or Ctrl+C during an active run, and the web UI exposes a Stop button plus Esc. Both paths send an abort signal, persist `session.stopped`, and leave the session resumable through `aginti resume <session-id>`.
16
+ Runs can be stopped without corrupting session state. The CLI listens for Ctrl+C during an active run, and Esc stops only when no ASAP pipe message is waiting to be applied. The web UI exposes a Stop button plus Esc. Stop paths send an abort signal, persist `session.stopped`, and leave the session resumable through `aginti resume <session-id>`.
17
17
 
18
18
  Default execution is Docker workspace mode with package installs approved inside the sandbox. The project is mounted at `/workspace`; persistent agent toolchain folders are mounted at `/aginti-home`, `/aginti-cache`, and `/aginti-env` from `~/.agintiflow/docker/`. Python, conda, and other language-level environments should be installed under `/aginti-env` so they survive across runs. Apt/apk package changes are ephemeral unless the Docker image is rebuilt.
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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",
@@ -42,6 +42,7 @@
42
42
  "scripts/setup-agent-toolchain-docker.sh",
43
43
  "scripts/real-deepseek-capabilities.js",
44
44
  "scripts/smoke-auxiliary-tools.js",
45
+ "scripts/smoke-auth.js",
45
46
  "scripts/smoke-cli-chat.js",
46
47
  "scripts/smoke-coding-tools.js",
47
48
  "scripts/smoke-capabilities.js",
@@ -65,13 +66,14 @@
65
66
  "setup:toolchain-docker": "scripts/setup-agent-toolchain-docker.sh",
66
67
  "smoke:coding-tools": "node scripts/smoke-coding-tools.js",
67
68
  "smoke:auxiliary-tools": "node scripts/smoke-auxiliary-tools.js",
69
+ "smoke:auth": "node scripts/smoke-auth.js",
68
70
  "smoke:cli-chat": "node scripts/smoke-cli-chat.js",
69
71
  "smoke:skills": "node scripts/smoke-skills.js",
70
72
  "smoke:toolchain-docker": "node scripts/smoke-toolchain-docker.js",
71
73
  "smoke:inbox": "node scripts/smoke-inbox.js",
72
74
  "smoke:web-api": "node scripts/smoke-web-api.js",
73
75
  "real:deepseek": "node scripts/real-deepseek-capabilities.js",
74
- "test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:auxiliary-tools && npm run smoke:capabilities && npm run smoke:skills && npm run smoke:cli-chat && npm run smoke:inbox",
76
+ "test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:auxiliary-tools && npm run smoke:auth && npm run smoke:capabilities && npm run smoke:skills && npm run smoke:cli-chat && npm run smoke:inbox",
75
77
  "pack:dry-run": "npm pack --dry-run",
76
78
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
77
79
  },
package/public/app.js CHANGED
@@ -22,9 +22,9 @@ const translations = {
22
22
  projectStatusTitle: "Project folder",
23
23
  setupTitle: "Provider setup",
24
24
  setupHelp:
25
- "DeepSeek/OpenAI keys are missing. Use mock mode, export an env var, or save a project-local DeepSeek key.",
25
+ "DeepSeek/OpenAI/Qwen keys are missing. Use mock mode, export an env var, or save a project-local model key.",
26
26
  setupEnvHelp:
27
- "Env vars: DEEPSEEK_API_KEY, OPENAI_API_KEY, LLM_API_KEY, and optional GRSAI for image generation. Mock mode remains available for local tests.",
27
+ "Env vars: DEEPSEEK_API_KEY, OPENAI_API_KEY, QWEN_API_KEY, LLM_API_KEY, and optional GRSAI for image generation. Mock mode remains available for local tests.",
28
28
  setupProviderLabel: "Provider",
29
29
  setupKeyLabel: "API key",
30
30
  saveKeyButton: "Save local key",
@@ -714,6 +714,7 @@ const ariaLabelNodes = [...document.querySelectorAll("[data-i18n-aria-label]")];
714
714
  const defaults = {
715
715
  openai: "gpt-5.4-mini",
716
716
  deepseek: "deepseek-v4-flash",
717
+ qwen: "qwen-plus",
717
718
  mock: "mock-agent",
718
719
  };
719
720
 
@@ -783,12 +784,14 @@ function renderKeyStatus(status = lastKeyStatus) {
783
784
  if (!status) return;
784
785
  keyStatusEl.textContent = `${t("keysLabel")}: OpenAI ${
785
786
  status.openai ? t("availableLabel") : t("missingLabel")
786
- } · DeepSeek ${status.deepseek ? t("availableLabel") : t("missingLabel")} · GRS AI ${
787
+ } · DeepSeek ${status.deepseek ? t("availableLabel") : t("missingLabel")} · Qwen ${
788
+ status.qwen ? t("availableLabel") : t("missingLabel")
789
+ } · GRS AI ${
787
790
  status.grsai ? t("availableLabel") : t("missingLabel")
788
791
  } · ${t("mockLabel")} ${
789
792
  status.mock ? t("availableLabel") : t("missingLabel")
790
793
  }`;
791
- if (setupCardEl) setupCardEl.hidden = Boolean(status.openai || status.deepseek);
794
+ if (setupCardEl) setupCardEl.hidden = Boolean(status.openai || status.deepseek || status.qwen);
792
795
  }
793
796
 
794
797
  function renderProjectStatus(info = projectInfo) {
@@ -2278,6 +2281,7 @@ providerField.addEventListener("change", () => {
2278
2281
  !modelField.value.trim() ||
2279
2282
  modelField.value === defaults.openai ||
2280
2283
  modelField.value === defaults.deepseek ||
2284
+ modelField.value === defaults.qwen ||
2281
2285
  modelField.value === defaults.mock
2282
2286
  ) {
2283
2287
  modelField.value = defaults[providerField.value] || "";
@@ -2513,6 +2517,7 @@ async function loadConfig() {
2513
2517
  taskProfiles = data.taskProfiles || [];
2514
2518
  projectInfo = data.project || null;
2515
2519
  defaults.openai = data.defaults?.openai?.model || defaults.openai;
2520
+ defaults.qwen = data.defaults?.qwen?.model || defaults.qwen;
2516
2521
  defaults.deepseek = routingPresets.fast?.model || data.defaults?.deepseek?.model || defaults.deepseek;
2517
2522
  defaults.mock = data.defaults?.mock?.model || defaults.mock;
2518
2523
 
package/public/index.html CHANGED
@@ -48,7 +48,7 @@
48
48
  DeepSeek/OpenAI keys are missing. Use mock mode, export an env var, or save a project-local DeepSeek key.
49
49
  </p>
50
50
  <p class="subtle" data-i18n="setupEnvHelp">
51
- Env vars: DEEPSEEK_API_KEY, OPENAI_API_KEY, or LLM_API_KEY. Mock mode remains available for local tests.
51
+ Env vars: DEEPSEEK_API_KEY, OPENAI_API_KEY, QWEN_API_KEY, or LLM_API_KEY. Mock mode remains available for local tests.
52
52
  </p>
53
53
  </div>
54
54
  <div class="grid">
@@ -57,6 +57,7 @@
57
57
  <select id="setup-provider">
58
58
  <option value="deepseek">DeepSeek</option>
59
59
  <option value="openai">OpenAI</option>
60
+ <option value="qwen">Qwen</option>
60
61
  <option value="grsai">GRS AI image</option>
61
62
  </select>
62
63
  </label>
@@ -88,6 +89,7 @@
88
89
  <select id="provider" name="provider">
89
90
  <option value="deepseek">DeepSeek</option>
90
91
  <option value="openai">OpenAI</option>
92
+ <option value="qwen">Qwen</option>
91
93
  <option value="mock" data-i18n="mockProviderOption">Mock local</option>
92
94
  </select>
93
95
  </label>
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { normalizeAuthProvider } from "../src/auth-onboarding.js";
8
+ import { getProviderDefaults } from "../src/model-routing.js";
9
+ import { providerKeyStatus, setProviderKey } from "../src/project.js";
10
+
11
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-auth-"));
13
+
14
+ function assert(condition, message) {
15
+ if (!condition) throw new Error(message);
16
+ }
17
+
18
+ async function runCli(args, stdin = "") {
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawn(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), ...args], {
21
+ cwd: tempRoot,
22
+ stdio: ["pipe", "pipe", "pipe"],
23
+ env: {
24
+ ...process.env,
25
+ AGINTIFLOW_RUNTIME_DIR: "",
26
+ },
27
+ });
28
+ let stdout = "";
29
+ let stderr = "";
30
+ const timer = setTimeout(() => {
31
+ child.kill("SIGTERM");
32
+ reject(new Error("auth smoke command timed out"));
33
+ }, 12000);
34
+ child.stdout.on("data", (chunk) => {
35
+ stdout += String(chunk);
36
+ });
37
+ child.stderr.on("data", (chunk) => {
38
+ stderr += String(chunk);
39
+ });
40
+ child.on("error", (error) => {
41
+ clearTimeout(timer);
42
+ reject(error);
43
+ });
44
+ child.on("close", (code) => {
45
+ clearTimeout(timer);
46
+ if (code === 0) resolve(stdout);
47
+ else reject(new Error(`auth smoke command failed ${code}\n${stdout}\n${stderr}`));
48
+ });
49
+ child.stdin.end(stdin);
50
+ });
51
+ }
52
+
53
+ try {
54
+ assert(normalizeAuthProvider("auxilliary") === "grsai", "auxilliary alias did not normalize to grsai");
55
+ assert(normalizeAuthProvider("qwen") === "qwen", "qwen provider did not normalize");
56
+ const qwenDefaults = getProviderDefaults("qwen");
57
+ assert(qwenDefaults.provider === "qwen" && qwenDefaults.model, "qwen provider defaults are not available");
58
+
59
+ await setProviderKey(tempRoot, "qwen", "test-qwen-key");
60
+ let status = providerKeyStatus(tempRoot);
61
+ assert(status.qwen, "qwen key status was not detected");
62
+ assert(status.envVars.qwen.includes("QWEN_API_KEY"), "qwen env var name was not reported");
63
+
64
+ await runCli(["keys", "set", "openai", "--stdin"], "test-openai-key");
65
+ await runCli(["keys", "set", "grsai", "--stdin"], "test-grsai-key");
66
+ status = providerKeyStatus(tempRoot);
67
+ assert(status.openai && status.grsai && status.qwen, "stored auth keys were not detected");
68
+
69
+ const keysOutput = await runCli(["keys", "status"]);
70
+ assert(keysOutput.includes("qwen=available"), "keys status did not include qwen");
71
+ assert(!keysOutput.includes("test-openai-key") && !keysOutput.includes("test-qwen-key"), "keys status leaked a raw key");
72
+
73
+ console.log(
74
+ JSON.stringify(
75
+ {
76
+ ok: true,
77
+ projectRoot: tempRoot,
78
+ checks: ["normalize-auth-provider", "qwen-defaults", "qwen-key-status", "cli-key-status-redacted"],
79
+ },
80
+ null,
81
+ 2
82
+ )
83
+ );
84
+ } finally {
85
+ await fs.rm(tempRoot, { recursive: true, force: true });
86
+ }
@@ -37,6 +37,7 @@ try {
37
37
  assert(capabilities.project.instructionsPresent, "capabilities did not report AGINTI.md");
38
38
  assert(capabilities.project.sharedSessionFolder, "capabilities did not report shared session folder");
39
39
  assert(capabilities.keys?.mock === true, "capabilities did not report mock availability");
40
+ assert(typeof capabilities.keys?.qwen === "boolean", "capabilities did not report qwen key status");
40
41
  assert(
41
42
  capabilities.checks.some((check) => check.name === "npm-prefix-test-policy" && check.ok),
42
43
  "npm --prefix test policy is not allowed"
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { buildPromptLayout, stripMarkdown } from "../src/interactive-cli.js";
7
+ import { buildPromptLayout, classifyEscapeAction, stripMarkdown } from "../src/interactive-cli.js";
8
8
 
9
9
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
10
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-cli-chat-"));
@@ -112,6 +112,15 @@ try {
112
112
  if (!queuedText.includes("run running · tool: apply_patch") || !queuedText.includes("→ apply this") || !queuedText.includes("↳ run this") || !queuedText.includes("cwd /tmp/aginti-project")) {
113
113
  throw new Error("terminal prompt layout did not render live input queue and cwd footer");
114
114
  }
115
+ if (classifyEscapeAction({ active: false }) !== "noop") {
116
+ throw new Error("idle Esc should not redraw or clear the prompt");
117
+ }
118
+ if (classifyEscapeAction({ active: true, pendingAsap: [{ content: "apply now" }] }) !== "wait-for-asap") {
119
+ throw new Error("active Esc should wait when ASAP pipe messages are pending");
120
+ }
121
+ if (classifyEscapeAction({ active: true, pendingAsap: [] }) !== "abort") {
122
+ throw new Error("active Esc should abort when no ASAP pipe messages are pending");
123
+ }
115
124
 
116
125
  await runCli(["init"], "");
117
126
  const instructions = await fs.readFile(path.join(tempRoot, "AGINTI.md"), "utf8");
@@ -163,7 +172,7 @@ try {
163
172
  {
164
173
  ok: true,
165
174
  projectRoot: tempRoot,
166
- checks: ["markdown-render", "markdown-table-no-duplicate", "patch-diff-render", "prompt-layout", "live-input-status-layout", "agent-response-gutter", "aginti-md", "instructions-command", "skills-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest", "resume-history-full"],
175
+ checks: ["markdown-render", "markdown-table-no-duplicate", "patch-diff-render", "prompt-layout", "escape-policy", "live-input-status-layout", "agent-response-gutter", "aginti-md", "instructions-command", "skills-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest", "resume-history-full"],
167
176
  },
168
177
  null,
169
178
  2
@@ -87,6 +87,7 @@ try {
87
87
 
88
88
  const keyStatus = await fetchJson("/api/keys/status");
89
89
  if (typeof keyStatus.keyStatus?.deepseek !== "boolean") throw new Error("key status endpoint is invalid");
90
+ if (typeof keyStatus.keyStatus?.qwen !== "boolean") throw new Error("qwen key status is missing");
90
91
  if ("localEnvPath" in keyStatus.keyStatus) throw new Error("key status leaked a local env path");
91
92
  const capabilities = await fetchJson("/api/capabilities");
92
93
  if (capabilities.project?.root !== runtimeDir || !Array.isArray(capabilities.checks)) {
@@ -106,6 +107,14 @@ try {
106
107
  if (!savedKey.ok || !savedKey.keyStatus?.deepseek || "apiKey" in savedKey || "key" in savedKey) {
107
108
  throw new Error("local key save endpoint returned invalid or sensitive data");
108
109
  }
110
+ const savedQwenKey = await fetchJson("/api/keys/qwen", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ apiKey: "test-qwen-key-not-real" }),
114
+ });
115
+ if (!savedQwenKey.ok || !savedQwenKey.keyStatus?.qwen || "apiKey" in savedQwenKey || "key" in savedQwenKey) {
116
+ throw new Error("qwen local key save endpoint returned invalid or sensitive data");
117
+ }
109
118
 
110
119
  const status = await fetchJson("/api/sandbox/status");
111
120
  if (!status.status?.workspaceReadable) throw new Error("sandbox status did not report a readable workspace");
@@ -1,8 +1,60 @@
1
1
  import readline from "node:readline/promises";
2
+ import { emitKeypressEvents } from "node:readline";
2
3
  import { stdin as input, stdout as output } from "node:process";
3
4
  import { Writable } from "node:stream";
4
5
  import { providerKeyStatus, setProviderKey } from "./project.js";
5
6
 
7
+ export const MAIN_AUTH_PROVIDERS = [
8
+ {
9
+ id: "deepseek",
10
+ label: "DeepSeek",
11
+ keyName: "DEEPSEEK_API_KEY",
12
+ description: "default fast/pro route",
13
+ },
14
+ {
15
+ id: "openai",
16
+ label: "OpenAI",
17
+ keyName: "OPENAI_API_KEY",
18
+ description: "OpenAI-compatible fallback",
19
+ },
20
+ {
21
+ id: "qwen",
22
+ label: "Qwen",
23
+ keyName: "QWEN_API_KEY",
24
+ description: "Qwen OpenAI-compatible route",
25
+ },
26
+ ];
27
+
28
+ const AUXILIARY_AUTH_PROVIDER = {
29
+ id: "grsai",
30
+ label: "GRS AI / Nano Banana",
31
+ keyName: "GRSAI",
32
+ description: "optional image generation",
33
+ };
34
+
35
+ const AUTH_ALIASES = {
36
+ auxiliary: "grsai",
37
+ auxilliary: "grsai",
38
+ image: "grsai",
39
+ imagegen: "grsai",
40
+ grs: "grsai",
41
+ grsai: "grsai",
42
+ deepseek: "deepseek",
43
+ ds: "deepseek",
44
+ openai: "openai",
45
+ qwen: "qwen",
46
+ };
47
+
48
+ export function normalizeAuthProvider(provider = "", fallback = "deepseek") {
49
+ const normalized = AUTH_ALIASES[String(provider || "").trim().toLowerCase()] || String(provider || "").trim().toLowerCase();
50
+ return ["deepseek", "openai", "qwen", "grsai"].includes(normalized) ? normalized : fallback;
51
+ }
52
+
53
+ function providerLabel(provider = "") {
54
+ const match = [...MAIN_AUTH_PROVIDERS, AUXILIARY_AUTH_PROVIDER].find((item) => item.id === provider);
55
+ return match?.label || provider;
56
+ }
57
+
6
58
  class MutedWritable extends Writable {
7
59
  constructor(target) {
8
60
  super();
@@ -16,8 +68,55 @@ class MutedWritable extends Writable {
16
68
  }
17
69
  }
18
70
 
19
- export async function promptHidden(promptText) {
20
- if (!input.isTTY || !output.isTTY) return "";
71
+ export async function promptSecret(promptText, { allowEscape = true } = {}) {
72
+ if (!input.isTTY || !output.isTTY) return { value: "", skipped: true };
73
+
74
+ if (typeof input.setRawMode === "function") {
75
+ return new Promise((resolve, reject) => {
76
+ emitKeypressEvents(input);
77
+ const wasRaw = Boolean(input.isRaw);
78
+ let value = "";
79
+
80
+ const cleanup = () => {
81
+ input.off("keypress", handler);
82
+ if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
83
+ input.pause();
84
+ };
85
+
86
+ const finish = (result) => {
87
+ cleanup();
88
+ output.write("\n");
89
+ resolve(result);
90
+ };
91
+
92
+ const handler = (str = "", key = {}) => {
93
+ if (key.ctrl && key.name === "c") {
94
+ cleanup();
95
+ reject(Object.assign(new Error("Interrupted by ctrl-c."), { name: "AbortError", code: "ABORT_ERR" }));
96
+ return;
97
+ }
98
+ if (allowEscape && key.name === "escape") {
99
+ finish({ value: "", skipped: true });
100
+ return;
101
+ }
102
+ if (key.name === "return" || key.name === "enter" || key.sequence === "\r" || str === "\r") {
103
+ finish({ value: value.trim(), skipped: !value.trim() });
104
+ return;
105
+ }
106
+ if (key.name === "backspace" || key.name === "delete") {
107
+ value = value.slice(0, -1);
108
+ return;
109
+ }
110
+ if (key.ctrl || key.meta || key.sequence?.startsWith("\x1b")) return;
111
+ if (str) value += str.replace(/\r|\n/g, "");
112
+ };
113
+
114
+ output.write(promptText);
115
+ input.resume();
116
+ input.setRawMode(true);
117
+ input.on("keypress", handler);
118
+ });
119
+ }
21
120
 
22
121
  const mutedOutput = new MutedWritable(output);
23
122
  const rl = readline.createInterface({
@@ -31,19 +130,132 @@ export async function promptHidden(promptText) {
31
130
  mutedOutput.muted = true;
32
131
  const value = await rl.question("");
33
132
  output.write("\n");
34
- return String(value || "").trim();
133
+ const text = String(value || "").trim();
134
+ return { value: text, skipped: !text };
35
135
  } finally {
36
136
  mutedOutput.muted = false;
37
137
  rl.close();
38
138
  }
39
139
  }
40
140
 
141
+ export async function promptHidden(promptText) {
142
+ const result = await promptSecret(promptText, { allowEscape: true });
143
+ return typeof result === "string" ? result : result.value || "";
144
+ }
145
+
146
+ function renderProviderPicker({ providers, selected, title, status }) {
147
+ const lines = [
148
+ `\n${title}`,
149
+ "Use Up/Down to choose, Enter to confirm, Esc to go back/skip.",
150
+ `Current key status: ${status}`,
151
+ "",
152
+ ...providers.map((provider, index) => {
153
+ const cursor = index === selected ? ">" : " ";
154
+ return `${cursor} ${provider.label.padEnd(10)} ${provider.keyName.padEnd(16)} ${provider.description}`;
155
+ }),
156
+ ];
157
+ return lines.join("\n");
158
+ }
159
+
160
+ export async function chooseAuthProvider({
161
+ providers = MAIN_AUTH_PROVIDERS,
162
+ title = "Choose main model API key",
163
+ initialProvider = "deepseek",
164
+ projectRoot = process.cwd(),
165
+ } = {}) {
166
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") {
167
+ return normalizeAuthProvider(initialProvider, providers[0]?.id || "deepseek");
168
+ }
169
+
170
+ return new Promise((resolve, reject) => {
171
+ emitKeypressEvents(input);
172
+ const wasRaw = Boolean(input.isRaw);
173
+ const status = providerKeyStatus(projectRoot);
174
+ let selected = Math.max(
175
+ providers.findIndex((provider) => provider.id === normalizeAuthProvider(initialProvider, providers[0]?.id)),
176
+ 0
177
+ );
178
+ let renderedLines = 0;
179
+
180
+ const cleanup = () => {
181
+ input.off("keypress", handler);
182
+ if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
183
+ input.pause();
184
+ output.write("\x1b[?25h");
185
+ };
186
+
187
+ const clear = () => {
188
+ if (renderedLines <= 0) return;
189
+ output.write(`\x1b[${renderedLines - 1}A`);
190
+ for (let index = 0; index < renderedLines; index += 1) {
191
+ output.write("\r\x1b[2K");
192
+ if (index < renderedLines - 1) output.write("\x1b[1B");
193
+ }
194
+ output.write(`\x1b[${renderedLines - 1}A\r`);
195
+ };
196
+
197
+ const render = () => {
198
+ clear();
199
+ const providerStatus = providers
200
+ .map((provider) => `${provider.label}=${status[provider.id] ? "available" : "missing"}`)
201
+ .join(" · ");
202
+ const text = renderProviderPicker({
203
+ providers,
204
+ selected,
205
+ title,
206
+ status: providerStatus,
207
+ });
208
+ const lines = text.split("\n");
209
+ renderedLines = lines.length;
210
+ output.write(`\x1b[?25l${text}`);
211
+ };
212
+
213
+ const finish = (value) => {
214
+ clear();
215
+ cleanup();
216
+ resolve(value);
217
+ };
218
+
219
+ const handler = (_str = "", key = {}) => {
220
+ if (key.ctrl && key.name === "c") {
221
+ clear();
222
+ cleanup();
223
+ reject(Object.assign(new Error("Interrupted by ctrl-c."), { name: "AbortError", code: "ABORT_ERR" }));
224
+ return;
225
+ }
226
+ if (key.name === "escape") {
227
+ finish("");
228
+ return;
229
+ }
230
+ if (key.name === "up") {
231
+ selected = (selected - 1 + providers.length) % providers.length;
232
+ render();
233
+ return;
234
+ }
235
+ if (key.name === "down") {
236
+ selected = (selected + 1) % providers.length;
237
+ render();
238
+ return;
239
+ }
240
+ if (key.name === "return" || key.name === "enter" || key.sequence === "\r") {
241
+ finish(providers[selected].id);
242
+ }
243
+ };
244
+
245
+ input.resume();
246
+ input.setRawMode(true);
247
+ input.on("keypress", handler);
248
+ render();
249
+ });
250
+ }
251
+
41
252
  export function shouldPromptForDeepSeek(args = {}, projectRoot = process.cwd()) {
42
253
  const provider = String(args.provider || "").toLowerCase();
43
- if (provider === "mock" || provider === "openai") return false;
254
+ if (provider === "mock" || provider === "openai" || provider === "qwen") return false;
44
255
  if (process.env.AGINTIFLOW_NO_AUTH_PROMPT === "1") return false;
45
256
  if (!input.isTTY || !output.isTTY) return false;
46
- return !providerKeyStatus(projectRoot).deepseek;
257
+ const status = providerKeyStatus(projectRoot);
258
+ return !status.deepseek && !status.openai && !status.qwen;
47
259
  }
48
260
 
49
261
  export async function promptAndSaveDeepSeekKey(projectRoot = process.cwd(), options = {}) {
@@ -60,3 +272,60 @@ export async function promptAndSaveDeepSeekKey(projectRoot = process.cwd(), opti
60
272
  path: result.path,
61
273
  };
62
274
  }
275
+
276
+ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
277
+ const status = providerKeyStatus(projectRoot);
278
+ const initialProvider = normalizeAuthProvider(options.provider || options.initialProvider || "deepseek", "deepseek");
279
+ const directProvider =
280
+ options.provider && ["deepseek", "openai", "qwen", "grsai"].includes(normalizeAuthProvider(options.provider, ""))
281
+ ? normalizeAuthProvider(options.provider)
282
+ : "";
283
+ const mainProvider =
284
+ directProvider === "grsai"
285
+ ? ""
286
+ : directProvider ||
287
+ (await chooseAuthProvider({
288
+ projectRoot,
289
+ initialProvider,
290
+ title: "Choose the main model API key to save",
291
+ }));
292
+
293
+ const saved = [];
294
+ const skipped = [];
295
+
296
+ if (mainProvider) {
297
+ const current = status[mainProvider] ? "currently available; paste a new key to replace, or Esc to keep existing" : "missing";
298
+ const prompt = `${providerLabel(mainProvider)} main API key (${current}) [hidden]: `;
299
+ const secret = await promptSecret(prompt, { allowEscape: true });
300
+ if (secret.value) {
301
+ const result = await setProviderKey(projectRoot, mainProvider, secret.value);
302
+ saved.push(result);
303
+ } else {
304
+ skipped.push({ provider: mainProvider, reason: secret.skipped ? "skipped" : "empty" });
305
+ }
306
+ } else {
307
+ skipped.push({ provider: "main", reason: "skipped" });
308
+ }
309
+
310
+ if (options.includeAuxiliary !== false && directProvider !== "grsai") {
311
+ const auxStatus = providerKeyStatus(projectRoot);
312
+ const current = auxStatus.grsai ? "currently available; paste a new key to replace, or Esc to skip" : "optional; paste key or Esc to skip";
313
+ const secret = await promptSecret(`${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key (${current}) [hidden]: `, {
314
+ allowEscape: true,
315
+ });
316
+ if (secret.value) {
317
+ const result = await setProviderKey(projectRoot, "grsai", secret.value);
318
+ saved.push(result);
319
+ } else {
320
+ skipped.push({ provider: "grsai", reason: "skipped" });
321
+ }
322
+ } else if (directProvider === "grsai") {
323
+ const secret = await promptSecret(`${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key [hidden]: `, {
324
+ allowEscape: true,
325
+ });
326
+ if (secret.value) saved.push(await setProviderKey(projectRoot, "grsai", secret.value));
327
+ else skipped.push({ provider: "grsai", reason: "skipped" });
328
+ }
329
+
330
+ return { saved, skipped };
331
+ }
@@ -147,6 +147,7 @@ export async function buildCapabilityReport(projectRoot, packageVersion, config)
147
147
  capability("docker", Boolean(dockerStatus?.dockerAvailable), dockerStatus || {}),
148
148
  capability("deepseek-key", keyStatus.deepseek, { envVars: keyStatus.envVars.deepseek }),
149
149
  capability("openai-key", keyStatus.openai, { envVars: keyStatus.envVars.openai }),
150
+ capability("qwen-key", keyStatus.qwen, { envVars: keyStatus.envVars.qwen }),
150
151
  capability("grsai-key", keyStatus.grsai, {
151
152
  envVars: keyStatus.envVars.grsai,
152
153
  setup: "Optional for image generation. Run `aginti login grsai` or use `/auxilliary grsai` in chat.",
@@ -202,6 +203,7 @@ export async function buildCapabilityReport(projectRoot, packageVersion, config)
202
203
  keys: {
203
204
  deepseek: keyStatus.deepseek,
204
205
  openai: keyStatus.openai,
206
+ qwen: keyStatus.qwen,
205
207
  grsai: keyStatus.grsai,
206
208
  mock: true,
207
209
  localEnv: keyStatus.localEnv,
@@ -266,7 +268,9 @@ export function printCapabilityReport(report) {
266
268
  console.log(
267
269
  `keys: deepseek=${report.keys.deepseek ? "available" : "missing"} openai=${
268
270
  report.keys.openai ? "available" : "missing"
269
- } grsai=${report.keys.grsai ? "available" : "missing"} mock=available localEnv=${report.keys.localEnv}`
271
+ } qwen=${report.keys.qwen ? "available" : "missing"} grsai=${
272
+ report.keys.grsai ? "available" : "missing"
273
+ } mock=available localEnv=${report.keys.localEnv}`
270
274
  );
271
275
  for (const check of report.checks) {
272
276
  const suffix = check.version ? ` ${check.version}` : check.reason ? ` ${check.reason}` : check.hint ? ` ${check.hint}` : "";
package/src/cli.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from "./project.js";
17
17
  import { listTaskProfiles } from "./task-profiles.js";
18
18
  import { recommendedMaxStepsForTask } from "./engineering-guidance.js";
19
- import { promptAndSaveDeepSeekKey, promptHidden, shouldPromptForDeepSeek } from "./auth-onboarding.js";
19
+ import { normalizeAuthProvider, promptHidden, runAuthWizard, shouldPromptForDeepSeek } from "./auth-onboarding.js";
20
20
  import { listSkills, selectSkillsForGoal } from "./skill-library.js";
21
21
  import fs from "node:fs/promises";
22
22
  import path from "node:path";
@@ -267,13 +267,14 @@ export function parseArgs(argv) {
267
267
 
268
268
  function printUsage() {
269
269
  console.log(
270
- 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti skills [query] OR aginti login deepseek|openai|grsai OR aginti resume [latest|<session-id>] ["prompt"] OR aginti queue <session-id> "message" OR aginti [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|mock] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex] [--list-skills] [--sandbox-status|--sandbox-preflight] "your task"'
270
+ 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti skills [query] OR aginti auth [deepseek|openai|qwen|grsai] OR aginti login [deepseek|openai|qwen|grsai] OR aginti resume [latest|<session-id>] ["prompt"] OR aginti queue <session-id> "message" OR aginti [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|mock] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex] [--list-skills] [--sandbox-status|--sandbox-preflight] "your task"'
271
271
  );
272
272
  }
273
273
 
274
274
  function providerLabel(provider) {
275
275
  const normalized = String(provider || "").toLowerCase();
276
276
  if (normalized === "openai") return "OpenAI";
277
+ if (normalized === "qwen") return "Qwen";
277
278
  if (normalized === "grsai" || normalized === "auxiliary" || normalized === "auxilliary") return "GRSAI";
278
279
  return "DeepSeek";
279
280
  }
@@ -355,7 +356,9 @@ function printDoctorReport(report) {
355
356
  console.log(
356
357
  `keys: deepseek=${report.keys.deepseek ? "available" : "missing"} openai=${
357
358
  report.keys.openai ? "available" : "missing"
358
- } grsai=${report.keys.grsai ? "available" : "missing"} mock=available localEnv=${report.project.localEnvPresent}`
359
+ } qwen=${report.keys.qwen ? "available" : "missing"} grsai=${
360
+ report.keys.grsai ? "available" : "missing"
361
+ } mock=available localEnv=${report.project.localEnvPresent}`
359
362
  );
360
363
  console.log(
361
364
  `sandbox=${report.sandbox?.sandboxMode || "unknown"} docker=${
@@ -377,16 +380,14 @@ async function readStdin() {
377
380
 
378
381
  async function ensureDeepSeekKeyForOneShot(args) {
379
382
  if (!shouldPromptForDeepSeek(args, process.cwd())) return true;
380
- console.log("DeepSeek API key is not configured for this project.");
381
- console.log("Paste it once to save it in `.aginti/.env` with 0600 permissions, or press Enter to cancel.");
382
- const result = await promptAndSaveDeepSeekKey(process.cwd(), {
383
- promptText: "DeepSeek API key: ",
384
- });
385
- if (result.saved) {
386
- console.log(`saved ${result.keyName} to project-local ignored env`);
383
+ console.log("No main model API key is configured for this project.");
384
+ console.log("Choose DeepSeek, OpenAI, or Qwen, then paste a key to save in `.aginti/.env` with 0600 permissions.");
385
+ const result = await runAuthWizard(process.cwd(), { provider: args.provider || "" });
386
+ printAuthWizardResult(result);
387
+ if (result.saved.some((item) => item.provider !== "grsai")) {
387
388
  return true;
388
389
  }
389
- console.error("No DeepSeek key saved. Run `aginti login deepseek` later, or use `--provider mock` for local tests.");
390
+ console.error("No main key saved. Run `aginti auth` later, or use `--provider mock` for local tests.");
390
391
  return false;
391
392
  }
392
393
 
@@ -397,9 +398,9 @@ async function handleKeyCommand(argv) {
397
398
  console.log(
398
399
  `keys: deepseek=${status.deepseek ? "available" : "missing"} openai=${
399
400
  status.openai ? "available" : "missing"
400
- } grsai=${status.grsai ? "available" : "missing"} mock=available localEnv=${status.localEnv}`
401
+ } qwen=${status.qwen ? "available" : "missing"} grsai=${status.grsai ? "available" : "missing"} mock=available localEnv=${status.localEnv}`
401
402
  );
402
- console.log("env vars: DeepSeek=DEEPSEEK_API_KEY or LLM_API_KEY; OpenAI=OPENAI_API_KEY or LLM_API_KEY; image=GRSAI or GRSAI_API_KEY");
403
+ console.log("env vars: DeepSeek=DEEPSEEK_API_KEY or LLM_API_KEY; OpenAI=OPENAI_API_KEY or LLM_API_KEY; Qwen=QWEN_API_KEY; image=GRSAI or GRSAI_API_KEY");
403
404
  return;
404
405
  }
405
406
 
@@ -415,10 +416,22 @@ async function handleKeyCommand(argv) {
415
416
  return;
416
417
  }
417
418
 
418
- console.error("Usage: aginti keys status OR aginti keys set deepseek|openai|grsai [--stdin]");
419
+ console.error("Usage: aginti keys status OR aginti keys set deepseek|openai|qwen|grsai [--stdin]");
419
420
  process.exit(1);
420
421
  }
421
422
 
423
+ function printAuthWizardResult(result) {
424
+ if (result.saved.length > 0) {
425
+ for (const item of result.saved) {
426
+ console.log(`saved ${item.provider} key to project-local ignored env (${item.keyName})`);
427
+ }
428
+ }
429
+ if (result.saved.length === 0) console.log("No key saved.");
430
+ if (result.skipped.length > 0) {
431
+ console.log(`skipped: ${result.skipped.map((item) => item.provider).join(", ")}`);
432
+ }
433
+ }
434
+
422
435
  async function handleSessionsCommand(argv) {
423
436
  const [verb = "list", sessionId = ""] = argv;
424
437
  if (verb === "list") {
@@ -532,16 +545,22 @@ export async function main(argv = process.argv.slice(2)) {
532
545
  return;
533
546
  }
534
547
 
535
- if (argv[0] === "login") {
536
- const provider = argv[1] || "deepseek";
548
+ if (argv[0] === "auth" || argv[0] === "login") {
549
+ const provider = normalizeAuthProvider(argv[1] || "", "");
550
+ if (argv[0] === "auth" || (!provider && process.stdin.isTTY)) {
551
+ const result = await runAuthWizard(process.cwd(), { provider });
552
+ printAuthWizardResult(result);
553
+ return;
554
+ }
555
+ const target = provider || "deepseek";
537
556
  const key = argv.includes("--stdin") || !process.stdin.isTTY
538
557
  ? await readStdin()
539
- : await promptHidden(`${providerLabel(provider)} API key/token: `);
558
+ : await promptHidden(`${providerLabel(target)} API key/token: `);
540
559
  if (!key) {
541
560
  console.error("No key saved.");
542
561
  process.exit(1);
543
562
  }
544
- const result = await setProviderKey(process.cwd(), provider, key);
563
+ const result = await setProviderKey(process.cwd(), target, key);
545
564
  console.log(`saved ${result.provider} key to project-local ignored env (${result.keyName})`);
546
565
  return;
547
566
  }
@@ -3,11 +3,11 @@ import { emitKeypressEvents } from "node:readline";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
  import { runAgent } from "./agent-runner.js";
5
5
  import { loadConfig } from "./config.js";
6
- import { initProject, listProjectSessions, projectPaths, providerKeyStatus, readProjectInstructions, setProviderKey } from "./project.js";
6
+ import { initProject, listProjectSessions, projectPaths, providerKeyStatus, readProjectInstructions } from "./project.js";
7
7
  import { normalizePackageInstallPolicy, normalizeSandboxMode } from "./command-policy.js";
8
8
  import { defaultMaxStepsForProfile, normalizeTaskProfile } from "./task-profiles.js";
9
9
  import { recommendedMaxStepsForTask } from "./engineering-guidance.js";
10
- import { promptAndSaveDeepSeekKey, promptHidden, shouldPromptForDeepSeek } from "./auth-onboarding.js";
10
+ import { normalizeAuthProvider, runAuthWizard, shouldPromptForDeepSeek } from "./auth-onboarding.js";
11
11
  import { SessionStore } from "./session-store.js";
12
12
  import { listSkills, selectSkillsForGoal } from "./skill-library.js";
13
13
 
@@ -474,8 +474,8 @@ function printHelp() {
474
474
  "Commands:",
475
475
  " /help Show this help.",
476
476
  " /status Show active route, workspace, sandbox, and session.",
477
- " /login [deepseek|openai|grsai] Paste and save a project-local API key.",
478
- " /auth [deepseek|openai|grsai] Alias for /login.",
477
+ " /login [deepseek|openai|qwen|grsai] Pick, paste, and save project-local API keys.",
478
+ " /auth [deepseek|openai|qwen|grsai] Alias for /login.",
479
479
  " /instructions Show AGINTI.md project instructions status.",
480
480
  " /memory Alias for /instructions.",
481
481
  " /auxilliary [status|grsai|on|off|image]",
@@ -488,7 +488,7 @@ function printHelp() {
488
488
  " /web-search on|off Enable or disable the web_search tool.",
489
489
  " /scouts on|off|<1-10> Enable parallel DeepSeek scouts and set scout count.",
490
490
  " /routing <mode> Set routing: smart, fast, complex, manual.",
491
- " /provider <name> Set provider: deepseek, openai, mock.",
491
+ " /provider <name> Set provider: deepseek, openai, qwen, mock.",
492
492
  " /model <name> Set an explicit model, or /model auto.",
493
493
  " /docker on Use docker-workspace with approved package installs.",
494
494
  " /docker off Use host shell policy.",
@@ -501,7 +501,7 @@ function printHelp() {
501
501
  "Type / then Tab to autocomplete commands.",
502
502
  "While a run is active, Enter pipes a message into the current run (→), Tab queues it after finish (↳).",
503
503
  "Alt+Up edits the last piped message; Shift+Left edits the last queued message.",
504
- "Esc or Ctrl+C stops gracefully and prints a resume command.",
504
+ "Esc is ignored while idle. During a run, Esc waits for pending → pipe messages or stops if none; Ctrl+C always stops.",
505
505
  ].join("\n")
506
506
  );
507
507
  }
@@ -793,6 +793,11 @@ function createAbortError(message = "Aborted with Ctrl+C") {
793
793
  return error;
794
794
  }
795
795
 
796
+ export function classifyEscapeAction({ active = false, pendingAsap = [] } = {}) {
797
+ if (!active) return "noop";
798
+ return Array.isArray(pendingAsap) && pendingAsap.length > 0 ? "wait-for-asap" : "abort";
799
+ }
800
+
796
801
  function readTtyPrompt(options = {}) {
797
802
  return new Promise((resolve, reject) => {
798
803
  emitKeypressEvents(input);
@@ -966,10 +971,7 @@ function readTtyPrompt(options = {}) {
966
971
  return;
967
972
  }
968
973
  if (key.name === "escape") {
969
- buffer = "";
970
- cursor = 0;
971
- preferredColumn = null;
972
- redraw();
974
+ if (classifyEscapeAction({ active: false }) === "noop") return;
973
975
  return;
974
976
  }
975
977
  if (key.ctrl || key.meta) return;
@@ -1192,6 +1194,13 @@ class LiveRunInput {
1192
1194
  return;
1193
1195
  }
1194
1196
  if (key.name === "escape") {
1197
+ const action = classifyEscapeAction({ active: true, pendingAsap: this.pendingAsap });
1198
+ if (action === "wait-for-asap") {
1199
+ const count = this.pendingAsap.length;
1200
+ this.setStatus(`running · waiting to apply ${count} asap pipe message${count === 1 ? "" : "s"}`);
1201
+ this.redraw();
1202
+ return;
1203
+ }
1195
1204
  this.controller.abort(new Error("Interrupted by escape."));
1196
1205
  return;
1197
1206
  }
@@ -1431,48 +1440,46 @@ async function maybeOnboardDeepSeekKey(state) {
1431
1440
 
1432
1441
  printAgentMessage(
1433
1442
  [
1434
- "DeepSeek API key is not configured for this project.",
1435
- "Paste it once to save it in `.aginti/.env` with 0600 permissions, or press Enter to continue in mock mode.",
1443
+ "No main model API key is configured for this project.",
1444
+ "Choose DeepSeek, OpenAI, or Qwen, then paste a key to save in `.aginti/.env` with 0600 permissions.",
1445
+ "After that, you can optionally paste the auxiliary image key. Press Esc to skip.",
1436
1446
  ].join("\n")
1437
1447
  );
1438
- const result = await promptAndSaveDeepSeekKey(process.cwd(), {
1439
- promptText: "DeepSeek API key: ",
1440
- });
1441
- if (result.saved) {
1442
- printAgentMessage(`Saved ${result.keyName} to project-local ignored env.`);
1448
+ const result = await runAuthWizard(process.cwd(), { provider: state.provider || "", includeAuxiliary: true });
1449
+ applyAuthWizardResult(result, state);
1450
+ if (result.saved.some((item) => item.provider !== "grsai")) {
1443
1451
  return;
1444
1452
  }
1445
1453
 
1446
1454
  state.provider = "mock";
1447
1455
  state.routingMode = "manual";
1448
1456
  state.model = "mock-agent";
1449
- printAgentMessage("No key saved. Continuing in local mock mode. Use `/provider deepseek` after running `aginti login deepseek`.");
1450
- }
1451
-
1452
- async function promptAndSaveProviderKey(provider = "deepseek", state = null) {
1453
- const aliases = { auxiliary: "grsai", auxilliary: "grsai", image: "grsai", imagegen: "grsai" };
1454
- const candidate = aliases[String(provider || "").toLowerCase()] || String(provider || "").toLowerCase();
1455
- const normalized = ["openai", "deepseek", "grsai"].includes(candidate)
1456
- ? String(provider || "").toLowerCase()
1457
- : "deepseek";
1458
- const canonical = aliases[normalized] || normalized;
1459
- const labelText = canonical === "openai" ? "OpenAI" : canonical === "grsai" ? "GRSAI" : "DeepSeek";
1460
- const key = await promptHidden(`${labelText} API key/token (paste, Enter to save): `);
1461
- if (!key) {
1462
- printAgentMessage("No key saved.");
1463
- return;
1464
- }
1457
+ printAgentMessage("No main key saved. Continuing in local mock mode. Use `/auth` later to save DeepSeek, OpenAI, or Qwen.");
1458
+ }
1465
1459
 
1466
- const result = await setProviderKey(process.cwd(), canonical, key);
1460
+ function applyAuthWizardResult(result, state = null) {
1467
1461
  if (state) {
1468
- if (canonical !== "grsai") state.provider = canonical;
1462
+ const main = result.saved.find((item) => item.provider !== "grsai");
1463
+ if (main) state.provider = main.provider;
1469
1464
  if (state.routingMode === "manual" && state.model === "mock-agent") {
1470
1465
  state.routingMode = "smart";
1471
1466
  state.model = "";
1472
1467
  }
1473
- if (canonical === "grsai") state.allowAuxiliaryTools = true;
1468
+ if (result.saved.some((item) => item.provider === "grsai")) state.allowAuxiliaryTools = true;
1474
1469
  }
1475
- printAgentMessage(`Saved ${result.keyName} to project-local ignored env. Raw key was not printed.`);
1470
+ if (result.saved.length > 0) {
1471
+ printAgentMessage(
1472
+ result.saved.map((item) => `Saved ${item.keyName} to project-local ignored env. Raw key was not printed.`).join("\n")
1473
+ );
1474
+ } else {
1475
+ printAgentMessage("No key saved.");
1476
+ }
1477
+ }
1478
+
1479
+ async function promptAndSaveProviderKey(provider = "", state = null) {
1480
+ const canonical = normalizeAuthProvider(provider || "", "");
1481
+ const result = await runAuthWizard(process.cwd(), { provider: canonical, includeAuxiliary: canonical !== "grsai" });
1482
+ applyAuthWizardResult(result, state);
1476
1483
  }
1477
1484
 
1478
1485
  async function handleCommand(line, state, packageDir) {
@@ -1490,7 +1497,7 @@ async function handleCommand(line, state, packageDir) {
1490
1497
  printSystemLine(
1491
1498
  `keys deepseek=${keys.deepseek ? "available" : "missing"} openai=${keys.openai ? "available" : "missing"} grsai=${
1492
1499
  keys.grsai ? "available" : "missing"
1493
- }`
1500
+ } qwen=${keys.qwen ? "available" : "missing"}`
1494
1501
  );
1495
1502
  return true;
1496
1503
  }
@@ -88,6 +88,15 @@ export function getProviderDefaults(provider = "deepseek") {
88
88
  };
89
89
  }
90
90
 
91
+ if (provider === "qwen") {
92
+ return {
93
+ provider: "qwen",
94
+ apiKey: process.env.QWEN_API_KEY || "",
95
+ baseURL: process.env.QWEN_BASE_URL || process.env.LLM_BASE_URL || "https://dashscope.aliyuncs.com/compatible-mode/v1",
96
+ model: process.env.QWEN_DEFAULT_MODEL || process.env.LLM_MODEL || "qwen-plus",
97
+ };
98
+ }
99
+
91
100
  return {
92
101
  provider: "deepseek",
93
102
  apiKey: process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || "",
package/src/project.js CHANGED
@@ -252,6 +252,7 @@ export function providerKeyStatus(projectRoot = process.cwd()) {
252
252
  return {
253
253
  openai: Boolean(process.env.OPENAI_API_KEY || process.env.LLM_API_KEY),
254
254
  deepseek: Boolean(process.env.DEEPSEEK_API_KEY || process.env.LLM_API_KEY),
255
+ qwen: Boolean(process.env.QWEN_API_KEY),
255
256
  grsai: Boolean(process.env.GRSAI || process.env.GRSAI_API_KEY),
256
257
  mock: true,
257
258
  localEnv: env.loaded,
@@ -259,6 +260,7 @@ export function providerKeyStatus(projectRoot = process.cwd()) {
259
260
  envVars: {
260
261
  openai: ["OPENAI_API_KEY", "LLM_API_KEY"],
261
262
  deepseek: ["DEEPSEEK_API_KEY", "LLM_API_KEY"],
263
+ qwen: ["QWEN_API_KEY"],
262
264
  grsai: ["GRSAI", "GRSAI_API_KEY"],
263
265
  },
264
266
  };
@@ -274,9 +276,15 @@ export async function setProviderKey(projectRoot, provider, value) {
274
276
  };
275
277
  const canonicalProvider = aliases[normalizedProvider] || normalizedProvider;
276
278
  const keyName =
277
- canonicalProvider === "openai" ? "OPENAI_API_KEY" : canonicalProvider === "grsai" ? "GRSAI" : "DEEPSEEK_API_KEY";
278
- if (!["deepseek", "openai", "grsai"].includes(canonicalProvider)) {
279
- throw new Error("Provider must be deepseek, openai, or grsai.");
279
+ canonicalProvider === "openai"
280
+ ? "OPENAI_API_KEY"
281
+ : canonicalProvider === "qwen"
282
+ ? "QWEN_API_KEY"
283
+ : canonicalProvider === "grsai"
284
+ ? "GRSAI"
285
+ : "DEEPSEEK_API_KEY";
286
+ if (!["deepseek", "openai", "qwen", "grsai"].includes(canonicalProvider)) {
287
+ throw new Error("Provider must be deepseek, openai, qwen, or grsai.");
280
288
  }
281
289
 
282
290
  const keyValue = String(value || "").trim();
@@ -412,6 +420,7 @@ export async function doctorReport(projectRoot, packageVersion, config) {
412
420
  keys: {
413
421
  openai: keyStatus.openai,
414
422
  deepseek: keyStatus.deepseek,
423
+ qwen: keyStatus.qwen,
415
424
  grsai: keyStatus.grsai,
416
425
  mock: true,
417
426
  },
package/web.js CHANGED
@@ -113,7 +113,7 @@ function serializeRun(run) {
113
113
  function normalizePreferencePayload(body = {}, current = db.getPreferences()) {
114
114
  const modelPresets = getModelPresets();
115
115
  const providerCandidate = body.provider || current.provider || "deepseek";
116
- const provider = ["openai", "deepseek", "mock"].includes(providerCandidate) ? providerCandidate : "deepseek";
116
+ const provider = ["openai", "deepseek", "qwen", "mock"].includes(providerCandidate) ? providerCandidate : "deepseek";
117
117
  const routingMode =
118
118
  provider === "mock" ? "manual" : normalizeRoutingMode(body.routingMode || current.routingMode || "smart");
119
119
  const providerDefaults = getProviderDefaults(provider);
@@ -195,6 +195,7 @@ function publicKeyStatus(projectRoot = baseDir) {
195
195
  return {
196
196
  openai: status.openai,
197
197
  deepseek: status.deepseek,
198
+ qwen: status.qwen,
198
199
  grsai: status.grsai,
199
200
  mock: true,
200
201
  localEnv: status.localEnv,
@@ -570,6 +571,7 @@ app.get("/api/config", async (_req, res) => {
570
571
  defaults: {
571
572
  openai: publicProviderDefault("openai"),
572
573
  deepseek: publicProviderDefault("deepseek"),
574
+ qwen: publicProviderDefault("qwen"),
573
575
  mock: publicProviderDefault("mock"),
574
576
  headless: true,
575
577
  maxSteps: 24,