github-actions[bot] commited on
Commit
c8ae75d
·
1 Parent(s): 7ec23c7

deploy from github actions 2026-06-18

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +50 -0
  2. cli/.gitignore +2 -0
  3. cli/.npmignore +9 -0
  4. cli/LICENSE +42 -0
  5. cli/README.md +125 -0
  6. cli/cli.js +830 -0
  7. cli/hooks/postinstall.js +22 -0
  8. cli/hooks/sqliteRuntime.js +139 -0
  9. cli/hooks/trayRuntime.js +107 -0
  10. cli/package.json +48 -0
  11. cli/scripts/build-cli.js +273 -0
  12. cli/scripts/buildMitm.js +70 -0
  13. cli/src/cli/api/client.js +556 -0
  14. cli/src/cli/menus/apiKeys.js +233 -0
  15. cli/src/cli/menus/cliTools.js +618 -0
  16. cli/src/cli/menus/combos.js +477 -0
  17. cli/src/cli/menus/providers.js +846 -0
  18. cli/src/cli/menus/settings.js +184 -0
  19. cli/src/cli/terminalUI.js +121 -0
  20. cli/src/cli/tray/autostart.js +306 -0
  21. cli/src/cli/tray/icon.ico +0 -0
  22. cli/src/cli/tray/icon.png +0 -0
  23. cli/src/cli/tray/tray.js +322 -0
  24. cli/src/cli/tray/tray.ps1 +79 -0
  25. cli/src/cli/tray/trayWin.js +89 -0
  26. cli/src/cli/utils/clipboard.js +30 -0
  27. cli/src/cli/utils/display.js +156 -0
  28. cli/src/cli/utils/endpoint.js +32 -0
  29. cli/src/cli/utils/format.js +125 -0
  30. cli/src/cli/utils/input.js +156 -0
  31. cli/src/cli/utils/menuHelper.js +156 -0
  32. cli/src/cli/utils/modelSelector.js +136 -0
  33. custom-server.js +27 -0
  34. jsconfig.json +12 -0
  35. next.config.mjs +73 -0
  36. open-sse/.npmignore +8 -0
  37. open-sse/AGENTS.md +35 -0
  38. open-sse/config/appConstants.js +181 -0
  39. open-sse/config/codexInstructions.js +119 -0
  40. open-sse/config/constants.js +4 -0
  41. open-sse/config/defaultThinkingSignature.js +12 -0
  42. open-sse/config/errorConfig.js +85 -0
  43. open-sse/config/googleTtsLanguages.js +62 -0
  44. open-sse/config/kiroConstants.js +277 -0
  45. open-sse/config/mediaConfig.js +27 -0
  46. open-sse/config/models.js +13 -0
  47. open-sse/config/ollamaModels.js +19 -0
  48. open-sse/config/providerModels.js +83 -0
  49. open-sse/config/providers.js +19 -0
  50. open-sse/config/runtimeConfig.js +84 -0
Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for 9Router - HF Spaces 版本
2
+ # 基于 original Dockerfile,适配 HF Spaces 环境(端口 7860)
3
+ ARG NODE_IMAGE=node:22-alpine
4
+ FROM ${NODE_IMAGE} AS base
5
+ WORKDIR /app
6
+
7
+ FROM base AS builder
8
+
9
+ RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
10
+
11
+ COPY package.json ./
12
+ RUN --mount=type=cache,target=/root/.npm \
13
+ npm install
14
+
15
+ COPY . ./
16
+ ENV NEXT_TELEMETRY_DISABLED=1
17
+ RUN npm run build
18
+
19
+ FROM ${NODE_IMAGE} AS runner
20
+ WORKDIR /app
21
+
22
+ LABEL org.opencontainers.image.title="9router"
23
+
24
+ ENV NODE_ENV=production
25
+ ENV PORT=7860
26
+ ENV HOSTNAME=0.0.0.0
27
+ ENV NEXT_TELEMETRY_DISABLED=1
28
+ ENV DATA_DIR=/app/data
29
+
30
+ COPY --from=builder /app/public ./public
31
+ COPY --from=builder /app/.next/static ./.next/static
32
+ COPY --from=builder /app/.next/standalone ./
33
+ COPY --from=builder /app/custom-server.js ./custom-server.js
34
+ COPY --from=builder /app/open-sse ./open-sse
35
+ COPY --from=builder /app/src/mitm ./src/mitm
36
+ COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
37
+ COPY --from=builder /app/node_modules/next ./node_modules/next
38
+
39
+ RUN mkdir -p /app/data && chown -R node:node /app && \
40
+ mkdir -p /app/data-home && chown node:node /app/data-home && \
41
+ ln -sf /app/data-home /root/.9router 2>/dev/null || true
42
+
43
+ RUN apk --no-cache upgrade && apk --no-cache add su-exec && \
44
+ printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \
45
+ chmod +x /entrypoint.sh
46
+
47
+ EXPOSE 7860
48
+
49
+ ENTRYPOINT ["/entrypoint.sh"]
50
+ CMD ["node", "custom-server.js"]
cli/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ app/*
2
+ node_modules/*
cli/.npmignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore everything except what's in package.json "files"
2
+ *
3
+ !cli.js
4
+ !hooks/
5
+ !app/
6
+ !package.json
7
+ !README.md
8
+ !LICENSE
9
+
cli/LICENSE ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 9Router Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
cli/README.md ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 9Router - FREE AI Router & Token Saver
2
+
3
+ **Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.**
4
+
5
+ **Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
6
+
7
+ [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
8
+ [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
9
+ [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router)
10
+ [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router)
11
+ [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
12
+
13
+ <a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
14
+
15
+ [🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router)
16
+
17
+ ---
18
+
19
+ ## 🤔 Why 9Router?
20
+
21
+ **Stop wasting money, tokens and hitting limits:**
22
+
23
+ - ❌ Subscription quota expires unused every month
24
+ - ❌ Rate limits stop you mid-coding
25
+ - ❌ Tool outputs (git diff, grep, ls...) burn tokens fast
26
+ - ❌ Expensive APIs ($20-50/month per provider)
27
+
28
+ **9Router solves this:**
29
+
30
+ - ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens
31
+ - ✅ **Maximize subscriptions** - Track quota, use every bit before reset
32
+ - ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime
33
+ - ✅ **Multi-account** - Round-robin between accounts per provider
34
+ - ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI
35
+
36
+ ---
37
+
38
+ ## ⚡ Quick Start
39
+
40
+ **Option 1 — npm (recommended for desktop):**
41
+
42
+ ```bash
43
+ npm install -g 9router
44
+ 9router
45
+
46
+ # Or run directly with npx
47
+ npx 9router
48
+ ```
49
+
50
+ **Option 2 — Docker (server/VPS):**
51
+
52
+ ```bash
53
+ docker run -d --name 9router -p 20128:20128 \
54
+ -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data \
55
+ decolua/9router:latest
56
+ ```
57
+
58
+ Published images: [Docker Hub](https://hub.docker.com/r/decolua/9router) • [GHCR](https://github.com/decolua/9router/pkgs/container/9router) (multi-platform amd64/arm64).
59
+
60
+ 🎉 Dashboard opens at `http://localhost:20128`
61
+
62
+ **2. Connect a FREE provider (no signup needed):**
63
+
64
+ Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done!
65
+
66
+ **3. Use in your CLI tool:**
67
+
68
+ ```
69
+ Claude Code/Codex/OpenClaw/Cursor/Cline Settings:
70
+ Endpoint: http://localhost:20128/v1
71
+ API Key: [copy from dashboard]
72
+ Model: kr/claude-sonnet-4.5
73
+ ```
74
+
75
+ That's it! Start coding with FREE AI models.
76
+
77
+ ---
78
+
79
+ ## 🚀 CLI Options
80
+
81
+ ```bash
82
+ 9router # Start with default settings
83
+ 9router --port 8080 # Custom port
84
+ 9router --no-browser # Don't open browser
85
+ 9router --skip-update # Skip auto-update check
86
+ 9router --help # Show all options
87
+ ```
88
+
89
+ **Dashboard**: `http://localhost:20128/dashboard`
90
+
91
+ ---
92
+
93
+ ## 🛠️ Supported CLI Tools
94
+
95
+ Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider
96
+
97
+ Any tool supporting OpenAI/Claude-compatible API works.
98
+
99
+ ---
100
+
101
+ ## 💾 Data Location
102
+
103
+ - **macOS/Linux**: `~/.9router/db/data.sqlite`
104
+ - **Windows**: `%APPDATA%/9router/db/data.sqlite`
105
+ - **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist)
106
+
107
+ ---
108
+
109
+ ## 📚 Documentation
110
+
111
+ Full docs, advanced setup, video tutorials & development guide:
112
+
113
+ - **GitHub**: https://github.com/decolua/9router
114
+ - **Full README**: https://github.com/decolua/9router/blob/main/app/README.md
115
+ - **Website**: https://9router.com
116
+
117
+ ---
118
+
119
+ ## 🙏 Acknowledgments
120
+
121
+ - **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation
122
+
123
+ ## 📄 License
124
+
125
+ MIT License - see [LICENSE](LICENSE) for details.
cli/cli.js ADDED
@@ -0,0 +1,830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, exec, execSync } = require("child_process");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const https = require("https");
7
+ const os = require("os");
8
+
9
+ // Native spinner - no external dependency
10
+ function createSpinner(text) {
11
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ let i = 0;
13
+ let interval = null;
14
+ let currentText = text;
15
+ return {
16
+ start() {
17
+ if (process.stdout.isTTY) {
18
+ process.stdout.write(`\r${frames[0]} ${currentText}`);
19
+ interval = setInterval(() => {
20
+ process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
21
+ }, 80);
22
+ }
23
+ return this;
24
+ },
25
+ stop() {
26
+ if (interval) {
27
+ clearInterval(interval);
28
+ interval = null;
29
+ }
30
+ if (process.stdout.isTTY) {
31
+ process.stdout.write("\r\x1b[K");
32
+ }
33
+ },
34
+ succeed(msg) {
35
+ this.stop();
36
+ console.log(`✅ ${msg}`);
37
+ },
38
+ fail(msg) {
39
+ this.stop();
40
+ console.log(`❌ ${msg}`);
41
+ }
42
+ };
43
+ }
44
+
45
+ const pkg = require("./package.json");
46
+ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
47
+ const { ensureTrayRuntime } = require("./hooks/trayRuntime");
48
+ const args = process.argv.slice(2);
49
+
50
+ // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
51
+ // so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
52
+ // better-sqlite3 is optional. Logs to stderr only on failure.
53
+ try { ensureSqliteRuntime({ silent: true }); } catch {}
54
+
55
+ // Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
56
+ try { ensureTrayRuntime({ silent: true }); } catch {}
57
+
58
+ // Configuration constants
59
+ const APP_NAME = pkg.name; // Use from package.json
60
+ const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
61
+
62
+ const DEFAULT_PORT = 20128;
63
+ const DEFAULT_HOST = "0.0.0.0";
64
+
65
+ // First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
66
+ function getLanIp() {
67
+ for (const ifaces of Object.values(os.networkInterfaces())) {
68
+ for (const i of ifaces || []) {
69
+ if (i.family === "IPv4" && !i.internal) return i.address;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
76
+ function getDisplayHost() {
77
+ return host === DEFAULT_HOST ? "localhost" : host;
78
+ }
79
+ const MAX_PORT_ATTEMPTS = 10;
80
+ // Identifiers for killAllAppProcesses - only kill 9router specifically
81
+ const PROCESS_IDENTIFIERS = [
82
+ '9router' // Only package name - avoid killing other apps
83
+ ];
84
+
85
+ // Parse arguments
86
+ let port = DEFAULT_PORT;
87
+ let host = DEFAULT_HOST;
88
+ let noBrowser = false;
89
+ let skipUpdate = false;
90
+ let showLog = false;
91
+ let trayMode = false;
92
+
93
+ for (let i = 0; i < args.length; i++) {
94
+ if (args[i] === "--port" || args[i] === "-p") {
95
+ port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
96
+ i++;
97
+ } else if (args[i] === "--host" || args[i] === "-H") {
98
+ host = args[i + 1] || DEFAULT_HOST;
99
+ i++;
100
+ } else if (args[i] === "--no-browser" || args[i] === "-n") {
101
+ noBrowser = true;
102
+ } else if (args[i] === "--log" || args[i] === "-l") {
103
+ showLog = true;
104
+ } else if (args[i] === "--skip-update") {
105
+ skipUpdate = true;
106
+ } else if (args[i] === "--tray" || args[i] === "-t") {
107
+ trayMode = true;
108
+ process.env.TRAY_MODE = "1";
109
+ } else if (args[i] === "--help" || args[i] === "-h") {
110
+ console.log(`
111
+ Usage: ${APP_NAME} [options]
112
+
113
+ Options:
114
+ -p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
115
+ -H, --host <host> Host to bind (default: ${DEFAULT_HOST})
116
+ -n, --no-browser Don't open browser automatically
117
+ -l, --log Show server logs (default: hidden)
118
+ -t, --tray Run in system tray mode (background)
119
+ --skip-update Skip auto-update check
120
+ -h, --help Show this help message
121
+ -v, --version Show version
122
+ `);
123
+ process.exit(0);
124
+ } else if (args[i] === "--version" || args[i] === "-v") {
125
+ console.log(pkg.version);
126
+ process.exit(0);
127
+ }
128
+ }
129
+
130
+ // Auto-relaunch after update: detached process has no TTY → fallback to tray
131
+ if (skipUpdate && !trayMode && !process.stdin.isTTY) {
132
+ trayMode = true;
133
+ process.env.TRAY_MODE = "1";
134
+ }
135
+
136
+ // Always use Node.js runtime with absolute path
137
+ const RUNTIME = process.execPath;
138
+
139
+ // Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
140
+ function compareVersions(a, b) {
141
+ const partsA = a.split(".").map(Number);
142
+ const partsB = b.split(".").map(Number);
143
+ for (let i = 0; i < 3; i++) {
144
+ if (partsA[i] > partsB[i]) return 1;
145
+ if (partsA[i] < partsB[i]) return -1;
146
+ }
147
+ return 0;
148
+ }
149
+
150
+ // Get app data dir (matches app/src/lib/dataDir.js convention)
151
+ function getAppDataDir() {
152
+ return process.platform === "win32"
153
+ ? path.join(process.env.APPDATA || "", "9router")
154
+ : path.join(os.homedir(), ".9router");
155
+ }
156
+
157
+ // Kill PID from file (best-effort, removes file after)
158
+ function killByPidFile(pidFile) {
159
+ try {
160
+ if (!fs.existsSync(pidFile)) return;
161
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
162
+ if (!pid) return;
163
+ try {
164
+ if (process.platform === "win32") {
165
+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
166
+ } else {
167
+ process.kill(pid, "SIGKILL");
168
+ }
169
+ } catch { }
170
+ try { fs.unlinkSync(pidFile); } catch { }
171
+ } catch { }
172
+ }
173
+
174
+ // Kill tunnel processes (cloudflared/tailscale) by their PID files
175
+ function killTunnelByPidFile() {
176
+ const tunnelDir = path.join(getAppDataDir(), "tunnel");
177
+ killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
178
+ killByPidFile(path.join(tunnelDir, "tailscale.pid"));
179
+ }
180
+
181
+ // Kill cloudflared whose --url targets this app's port (covers stale PID file case)
182
+ function killCloudflaredByAppPort(appPort) {
183
+ if (!appPort) return [];
184
+ const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
185
+ const pids = [];
186
+ try {
187
+ if (process.platform === "win32") {
188
+ const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
189
+ const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
190
+ const lines = output.split("\n").slice(1).filter(l => l.trim());
191
+ lines.forEach(line => {
192
+ if (portMatchers.some(m => line.includes(m))) {
193
+ const match = line.match(/^"(\d+)"/);
194
+ if (match && match[1]) pids.push(match[1]);
195
+ }
196
+ });
197
+ } else {
198
+ const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
199
+ output.split("\n").forEach(line => {
200
+ if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
201
+ const parts = line.trim().split(/\s+/);
202
+ const pid = parts[0];
203
+ if (pid && !isNaN(pid)) pids.push(pid);
204
+ }
205
+ });
206
+ }
207
+ } catch { }
208
+ return pids;
209
+ }
210
+
211
+ // Kill all 9router processes
212
+ function killAllAppProcesses(appPort) {
213
+ return new Promise((resolve) => {
214
+ try {
215
+ // Kill MIT first (privileged process, needs special handling)
216
+ killProxyByPidFile();
217
+ // Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
218
+ killTunnelByPidFile();
219
+
220
+ const platform = process.platform;
221
+ let pids = [];
222
+
223
+ // Catch stale PID files: kill cloudflared bound to this app's port
224
+ pids.push(...killCloudflaredByAppPort(appPort));
225
+
226
+ if (platform === "win32") {
227
+ // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
228
+ try {
229
+ const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
230
+ const output = execSync(psCmd, {
231
+ encoding: "utf8",
232
+ windowsHide: true,
233
+ timeout: 5000
234
+ });
235
+ const lines = output.split("\n").slice(1).filter(l => l.trim());
236
+ lines.forEach(line => {
237
+ // Whitelist: real node process running 9router/cli.js, or next-server.
238
+ // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
239
+ const cmd = line.toLowerCase();
240
+ const isAppProcess =
241
+ (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
242
+ || cmd.includes("next-server");
243
+ if (isAppProcess) {
244
+ const match = line.match(/^"(\d+)"/);
245
+ if (match && match[1] && match[1] !== process.pid.toString()) {
246
+ pids.push(match[1]);
247
+ }
248
+ }
249
+ });
250
+ } catch (e) {
251
+ // No processes found or error - continue
252
+ }
253
+ } else {
254
+ // macOS/Linux: use ps to find all matching processes
255
+ try {
256
+ const output = execSync('ps aux 2>/dev/null', {
257
+ encoding: 'utf8',
258
+ timeout: 5000
259
+ });
260
+ const lines = output.split('\n');
261
+
262
+ lines.forEach(line => {
263
+ // Whitelist: real node process running 9router/cli.js, or next-server.
264
+ // Avoids killing grep/strace/editors/cursor that incidentally match "9router".
265
+ const cmd = line.toLowerCase();
266
+ const isAppProcess =
267
+ (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
268
+ || cmd.includes("next-server");
269
+ if (isAppProcess) {
270
+ const parts = line.trim().split(/\s+/);
271
+ const pid = parts[1];
272
+ if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
273
+ pids.push(pid);
274
+ }
275
+ }
276
+ });
277
+ } catch (e) {
278
+ // No processes found or error - continue
279
+ }
280
+ }
281
+
282
+ // Kill all found processes
283
+ if (pids.length > 0) {
284
+ pids.forEach(pid => {
285
+ try {
286
+ if (platform === "win32") {
287
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
288
+ } else {
289
+ execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
290
+ }
291
+ } catch (err) {
292
+ // Process already dead or can't kill - continue
293
+ }
294
+ });
295
+
296
+ // Wait for processes to fully terminate
297
+ setTimeout(() => resolve(), 1000);
298
+ } else {
299
+ resolve();
300
+ }
301
+ } catch (err) {
302
+ // Silent fail - continue anyway
303
+ resolve();
304
+ }
305
+ });
306
+ }
307
+
308
+ // Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
309
+ function sleepSync(ms) {
310
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
311
+ }
312
+
313
+ // Wait until process dies or timeout reached
314
+ function waitForExit(pid, timeoutMs) {
315
+ const deadline = Date.now() + timeoutMs;
316
+ while (Date.now() < deadline) {
317
+ try { process.kill(pid, 0); } catch { return true; }
318
+ sleepSync(100);
319
+ }
320
+ return false;
321
+ }
322
+
323
+ // Kill MIT server by PID file (runs privileged, needs special handling)
324
+ // Sends SIGTERM first so MIT can clean up host entries before dying.
325
+ function killProxyByPidFile() {
326
+ try {
327
+ const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
328
+ if (!fs.existsSync(pidFile)) return;
329
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
330
+ if (!pid) return;
331
+
332
+ if (process.platform === "win32") {
333
+ // Graceful first (lets server cleanup hosts), then force
334
+ try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
335
+ if (!waitForExit(pid, 1500)) {
336
+ try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
337
+ }
338
+ // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
339
+ if (!waitForExit(pid, 500)) {
340
+ try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
341
+ }
342
+ } else {
343
+ // SIGTERM via cached sudo token first
344
+ try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
345
+ catch { try { process.kill(pid, "SIGTERM"); } catch { } }
346
+ if (!waitForExit(pid, 1500)) {
347
+ try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
348
+ catch { try { process.kill(pid, "SIGKILL"); } catch { } }
349
+ }
350
+ }
351
+ try { fs.unlinkSync(pidFile); } catch { }
352
+ } catch { }
353
+ }
354
+
355
+ // Kill any process on specific port
356
+ function killProcessOnPort(port) {
357
+ return new Promise((resolve) => {
358
+ try {
359
+ const platform = process.platform;
360
+ let pid;
361
+
362
+ if (platform === "win32") {
363
+ try {
364
+ const output = execSync(`netstat -ano | findstr :${port}`, {
365
+ encoding: 'utf8',
366
+ shell: true,
367
+ windowsHide: true,
368
+ timeout: 5000
369
+ }).trim();
370
+ const lines = output.split('\n').filter(l => l.includes('LISTENING'));
371
+ if (lines.length > 0) {
372
+ pid = lines[0].trim().split(/\s+/).pop();
373
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
374
+ }
375
+ } catch (e) {
376
+ // Port is free or error
377
+ }
378
+ } else {
379
+ // macOS/Linux
380
+ try {
381
+ const pidOutput = execSync(`lsof -ti:${port}`, {
382
+ encoding: 'utf8',
383
+ stdio: ['pipe', 'pipe', 'ignore']
384
+ }).trim();
385
+ if (pidOutput) {
386
+ pid = pidOutput.split('\n')[0];
387
+ execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
388
+ }
389
+ } catch (e) {
390
+ // Port is free or error
391
+ }
392
+ }
393
+
394
+ // Wait for port to be released
395
+ setTimeout(() => resolve(), 500);
396
+ } catch (err) {
397
+ // Silent fail - continue anyway
398
+ resolve();
399
+ }
400
+ });
401
+ }
402
+
403
+
404
+ // Detect if running in restricted environment (Codespaces, Docker)
405
+ function isRestrictedEnvironment() {
406
+ // Check for Codespaces
407
+ if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
408
+ return "GitHub Codespaces";
409
+ }
410
+
411
+ // Check for Docker
412
+ if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
413
+ return "Docker";
414
+ }
415
+
416
+ return null;
417
+ }
418
+
419
+ // Check if new version available, return latest version or null
420
+ function checkForUpdate() {
421
+ return new Promise((resolve) => {
422
+ if (skipUpdate) {
423
+ resolve(null);
424
+ return;
425
+ }
426
+
427
+ const spinner = createSpinner("Checking for updates...").start();
428
+ let resolved = false;
429
+
430
+ const safetyTimeout = setTimeout(() => {
431
+ if (!resolved) {
432
+ resolved = true;
433
+ spinner.stop();
434
+ resolve(null);
435
+ }
436
+ }, 8000);
437
+
438
+ const done = (version) => {
439
+ if (resolved) return;
440
+ resolved = true;
441
+ clearTimeout(safetyTimeout);
442
+ spinner.stop();
443
+ resolve(version);
444
+ };
445
+
446
+ const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
447
+ let data = "";
448
+ res.on("data", chunk => data += chunk);
449
+ res.on("end", () => {
450
+ try {
451
+ const latest = JSON.parse(data);
452
+ if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
453
+ done(latest.version);
454
+ } else {
455
+ done(null);
456
+ }
457
+ } catch (e) {
458
+ done(null);
459
+ }
460
+ });
461
+ });
462
+
463
+ req.on("error", () => done(null));
464
+ req.on("timeout", () => { req.destroy(); done(null); });
465
+ });
466
+ }
467
+
468
+ // Open browser
469
+ function openBrowser(url) {
470
+ const platform = process.platform;
471
+ let cmd;
472
+
473
+ if (platform === "darwin") {
474
+ cmd = `open "${url}"`;
475
+ } else if (platform === "win32") {
476
+ cmd = `start "" "${url}"`;
477
+ } else {
478
+ cmd = `xdg-open "${url}"`;
479
+ }
480
+
481
+ exec(cmd, { windowsHide: true }, (err) => {
482
+ if (err) {
483
+ console.log(`Open browser manually: ${url}`);
484
+ }
485
+ });
486
+ }
487
+
488
+ // Find standalone server (bundled in bin/app for published package).
489
+ // Prefer custom-server.js (injects real socket IP) when present.
490
+ const standaloneDir = path.join(__dirname, "app");
491
+ const customServerPath = path.join(standaloneDir, "custom-server.js");
492
+ const serverPath = fs.existsSync(customServerPath)
493
+ ? customServerPath
494
+ : path.join(standaloneDir, "server.js");
495
+
496
+ if (!fs.existsSync(serverPath)) {
497
+ console.error("Error: Standalone build not found.");
498
+ console.error("Please run 'npm run build:cli' first.");
499
+ process.exit(1);
500
+ }
501
+
502
+ // Check for updates FIRST, then start server
503
+ checkForUpdate().then((latestVersion) => {
504
+ killAllAppProcesses(port).then(() => {
505
+ return killProcessOnPort(port);
506
+ }).then(() => {
507
+ startServer(latestVersion);
508
+ });
509
+ });
510
+
511
+ // Show interface selection menu
512
+ async function showInterfaceMenu(latestVersion) {
513
+ const { selectMenu } = require("./src/cli/utils/input");
514
+ const { clearScreen } = require("./src/cli/utils/display");
515
+ const { getEndpoint } = require("./src/cli/utils/endpoint");
516
+
517
+ clearScreen();
518
+
519
+ const displayHost = getDisplayHost();
520
+
521
+ // Detect tunnel/local mode for server URL display
522
+ let serverUrl;
523
+ try {
524
+ const { endpoint, tunnelEnabled } = await getEndpoint(port);
525
+ serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
526
+ } catch (e) {
527
+ serverUrl = `http://${displayHost}:${port}`;
528
+ }
529
+
530
+ const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
531
+
532
+ const menuItems = [];
533
+
534
+ if (latestVersion) {
535
+ menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
536
+ }
537
+
538
+ menuItems.push(
539
+ { label: "Web UI (Open in Browser)", icon: "🌐" },
540
+ { label: "Terminal UI (Interactive CLI)", icon: "💻" },
541
+ { label: "Hide to Tray (Background)", icon: "🔔" },
542
+ { label: "Exit", icon: "🚪" }
543
+ );
544
+
545
+ const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
546
+
547
+ const offset = latestVersion ? 1 : 0;
548
+
549
+ if (latestVersion && selected === 0) return "update";
550
+ if (selected === offset) return "web";
551
+ if (selected === offset + 1) return "terminal";
552
+ if (selected === offset + 2) return "hide";
553
+ return "exit";
554
+ }
555
+
556
+ const MAX_RESTARTS = 2;
557
+ const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
558
+
559
+ function startServer(latestVersion) {
560
+ const displayHost = getDisplayHost();
561
+ const url = `http://${displayHost}:${port}/dashboard`;
562
+ // Surface real network exposure when bound to all interfaces (default 0.0.0.0).
563
+ if (host === DEFAULT_HOST) {
564
+ const lanIp = getLanIp();
565
+ if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
566
+ }
567
+
568
+ let restartCount = 0;
569
+ let serverStartTime = Date.now();
570
+
571
+ const CRASH_LOG_LINES = 50;
572
+ let crashLog = [];
573
+
574
+ function spawnServer() {
575
+ serverStartTime = Date.now();
576
+ crashLog = [];
577
+ const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
578
+ cwd: standaloneDir,
579
+ stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
580
+ detached: true,
581
+ windowsHide: true,
582
+ env: {
583
+ ...buildEnvWithRuntime(process.env),
584
+ PORT: port.toString(),
585
+ HOSTNAME: host
586
+ }
587
+ });
588
+ if (!showLog && child.stderr) {
589
+ child.stderr.on("data", (data) => {
590
+ const lines = data.toString().split("\n").filter(Boolean);
591
+ crashLog.push(...lines);
592
+ if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
593
+ });
594
+ }
595
+ return child;
596
+ }
597
+
598
+ let server = spawnServer();
599
+
600
+ // Cleanup function - force kill server process
601
+ let isCleaningUp = false;
602
+ function cleanup() {
603
+ if (isCleaningUp) return;
604
+ isCleaningUp = true;
605
+ try {
606
+ // Kill tray if running
607
+ try {
608
+ const { killTray } = require("./src/cli/tray/tray");
609
+ killTray();
610
+ } catch (e) { }
611
+ // Kill MIT server (privileged process) via PID file
612
+ killProxyByPidFile();
613
+ // Kill cloudflared/tailscale via PID file (only this app's tunnel)
614
+ killTunnelByPidFile();
615
+ // Kill server process directly
616
+ if (server.pid) {
617
+ process.kill(server.pid, "SIGKILL");
618
+ }
619
+ // Also try to kill process group
620
+ process.kill(-server.pid, "SIGKILL");
621
+ } catch (e) { }
622
+ }
623
+
624
+ // Suppress all errors during shutdown (systray lib throws JSON parse errors)
625
+ let isShuttingDown = false;
626
+ process.on("uncaughtException", (err) => {
627
+ if (isShuttingDown) return;
628
+ console.error("Error:", err.message);
629
+ });
630
+
631
+ // Handle all exit scenarios
632
+ process.on("SIGINT", () => {
633
+ if (isShuttingDown) return;
634
+ isShuttingDown = true;
635
+ console.log("\nExiting...");
636
+ cleanup();
637
+ setTimeout(() => process.exit(0), 100);
638
+ });
639
+ process.on("SIGTERM", () => {
640
+ if (isShuttingDown) return;
641
+ isShuttingDown = true;
642
+ cleanup();
643
+ setTimeout(() => process.exit(0), 100);
644
+ });
645
+ process.on("SIGHUP", () => {
646
+ if (isShuttingDown) return;
647
+ isShuttingDown = true;
648
+ cleanup();
649
+ setTimeout(() => process.exit(0), 100);
650
+ });
651
+
652
+ // Initialize tray icon (runs alongside TUI)
653
+ const initTrayIcon = () => {
654
+ try {
655
+ const { initTray } = require("./src/cli/tray/tray");
656
+ initTray({
657
+ port,
658
+ onQuit: () => {
659
+ isShuttingDown = true;
660
+ console.log("\n👋 Shutting down from tray...");
661
+ cleanup();
662
+ setTimeout(() => process.exit(0), 100);
663
+ },
664
+ onOpenDashboard: () => openBrowser(url)
665
+ });
666
+ } catch (err) {
667
+ // Tray not available - continue without it
668
+ }
669
+ };
670
+
671
+ // Tray-only mode: no TUI, just tray icon
672
+ if (trayMode) {
673
+ // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
674
+ process.removeAllListeners("SIGHUP");
675
+ process.on("SIGHUP", () => {});
676
+
677
+ console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
678
+ console.log(`Server: http://${displayHost}:${port}`);
679
+
680
+ setTimeout(() => {
681
+ initTrayIcon();
682
+ console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
683
+ console.log(" Right-click tray icon to open dashboard or quit.\n");
684
+ }, 2000);
685
+
686
+ return;
687
+ }
688
+
689
+ // Wait for server to be ready, then show interface menu loop + tray
690
+ setTimeout(async () => {
691
+ // Start tray icon alongside TUI
692
+ initTrayIcon();
693
+
694
+ try {
695
+ while (true) {
696
+ const choice = await showInterfaceMenu(latestVersion);
697
+
698
+ if (choice === "update") {
699
+ isShuttingDown = true;
700
+ const { clearScreen } = require("./src/cli/utils/display");
701
+ clearScreen();
702
+ console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
703
+ console.log(`Run this after exit:\n`);
704
+ console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
705
+ cleanup();
706
+ await killAllAppProcesses(port);
707
+ await killProcessOnPort(port);
708
+ setTimeout(() => process.exit(0), 200);
709
+ return;
710
+ } else if (choice === "web") {
711
+ openBrowser(url);
712
+ // Wait for user to come back
713
+ const { pause } = require("./src/cli/utils/input");
714
+ await pause("\nPress Enter to go back to menu...");
715
+ } else if (choice === "terminal") {
716
+ // Start Terminal UI - it will return when user selects Back
717
+ const { startTerminalUI } = require("./src/cli/terminalUI");
718
+ await startTerminalUI(port);
719
+ // Loop continues, show menu again
720
+ } else if (choice === "hide") {
721
+ const { clearScreen } = require("./src/cli/utils/display");
722
+ clearScreen();
723
+
724
+ // Enable auto startup on OS boot
725
+ try {
726
+ const { enableAutoStart } = require("./src/cli/tray/autostart");
727
+ enableAutoStart(__filename);
728
+ } catch (e) { }
729
+
730
+ if (process.platform === "darwin") {
731
+ // macOS: keep current process alive — spawning a detached child puts
732
+ // it outside the login session so NSStatusItem silently fails.
733
+ process.removeAllListeners("SIGHUP");
734
+ process.on("SIGHUP", () => {});
735
+
736
+ console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`);
737
+ console.log(`🔔 9Router is running in tray (PID: ${process.pid})`);
738
+ console.log(` Server: http://${displayHost}:${port}`);
739
+ console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
740
+
741
+ // Tray already init'd at startup — just keep event loop alive.
742
+ return;
743
+ }
744
+
745
+ // Windows/Linux: spawn detached bgProcess (systray works fine in child)
746
+ console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
747
+
748
+ const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
749
+ detached: true,
750
+ stdio: "ignore",
751
+ windowsHide: true,
752
+ env: { ...process.env }
753
+ });
754
+ bgProcess.unref();
755
+
756
+ console.log(`🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
757
+ console.log(` Server: http://${displayHost}:${port}`);
758
+ console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
759
+
760
+ // cleanup() kills server so bgProcess can claim the port fresh
761
+ cleanup();
762
+ process.exit(0);
763
+ } else if (choice === "exit") {
764
+ isShuttingDown = true;
765
+ console.log("\nExiting...");
766
+ cleanup();
767
+ setTimeout(() => process.exit(0), 100);
768
+ }
769
+ }
770
+ } catch (err) {
771
+ console.error("Error:", err.message);
772
+ cleanup();
773
+ process.exit(1);
774
+ }
775
+ }, 3000);
776
+
777
+ function attachServerEvents() {
778
+ server.on("error", (err) => {
779
+ console.error("Failed to start server:", err.message);
780
+ if (!isShuttingDown) tryRestart();
781
+ else { cleanup(); process.exit(1); }
782
+ });
783
+
784
+ server.on("close", (code) => {
785
+ if (isShuttingDown || code === 0) {
786
+ process.exit(code || 0);
787
+ return;
788
+ }
789
+ tryRestart(code);
790
+ });
791
+ }
792
+
793
+ function tryRestart(code) {
794
+ const aliveMs = Date.now() - serverStartTime;
795
+ // Reset counter if last run was stable
796
+ if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
797
+
798
+ if (restartCount >= MAX_RESTARTS) {
799
+ console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
800
+ try {
801
+ const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
802
+ if (fs.existsSync(dbPath)) {
803
+ const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
804
+ if (db.settings) db.settings.mitmEnabled = false;
805
+ fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
806
+ }
807
+ } catch { /* best effort */ }
808
+ restartCount = 0;
809
+ server = spawnServer();
810
+ attachServerEvents();
811
+ return;
812
+ }
813
+
814
+ restartCount++;
815
+ const delay = Math.min(1000 * restartCount, 10000);
816
+ console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
817
+ if (crashLog.length) {
818
+ console.error("\n--- Server crash log ---");
819
+ crashLog.forEach(l => console.error(l));
820
+ console.error("--- End crash log ---\n");
821
+ }
822
+
823
+ setTimeout(() => {
824
+ server = spawnServer();
825
+ attachServerEvents();
826
+ }, delay);
827
+ }
828
+
829
+ attachServerEvents();
830
+ }
cli/hooks/postinstall.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ // Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
4
+ // `9router` start doesn't need network. Failure here is non-fatal —
5
+ // cli.js will retry at runtime if anything is missing.
6
+ const { ensureSqliteRuntime } = require("./sqliteRuntime");
7
+ const { ensureTrayRuntime } = require("./trayRuntime");
8
+
9
+ try {
10
+ ensureSqliteRuntime({ silent: false });
11
+ console.log("[9router] runtime SQLite deps ready");
12
+ } catch (e) {
13
+ console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
14
+ }
15
+
16
+ try {
17
+ ensureTrayRuntime({ silent: false });
18
+ } catch (e) {
19
+ console.warn(`[9router] tray runtime skipped: ${e.message}`);
20
+ }
21
+
22
+ process.exit(0);
cli/hooks/sqliteRuntime.js ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules
2
+ // (user-writable, avoids Windows EBUSY locks during npm i -g updates).
3
+ // sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in.
4
+ const { execSync, spawnSync } = require("child_process");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+
9
+ const BETTER_SQLITE3_VERSION = "12.6.2";
10
+
11
+ function getDataDir() {
12
+ if (process.env.DATA_DIR) return process.env.DATA_DIR;
13
+ return process.platform === "win32"
14
+ ? path.join(process.env.APPDATA || os.homedir(), "9router")
15
+ : path.join(os.homedir(), ".9router");
16
+ }
17
+
18
+ function getRuntimeDir() {
19
+ return path.join(getDataDir(), "runtime");
20
+ }
21
+
22
+ function getRuntimeNodeModules() {
23
+ return path.join(getRuntimeDir(), "node_modules");
24
+ }
25
+
26
+ function ensureRuntimeDir() {
27
+ const dir = getRuntimeDir();
28
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
29
+
30
+ // Minimal package.json so npm treats it as a project root
31
+ const pkgPath = path.join(dir, "package.json");
32
+ if (!fs.existsSync(pkgPath)) {
33
+ fs.writeFileSync(pkgPath, JSON.stringify({
34
+ name: "9router-runtime",
35
+ version: "1.0.0",
36
+ private: true,
37
+ description: "User-writable runtime deps for 9router (better-sqlite3 native binary)",
38
+ }, null, 2));
39
+ }
40
+ return dir;
41
+ }
42
+
43
+ function hasModule(name) {
44
+ return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
45
+ }
46
+
47
+ function isBetterSqliteBinaryValid() {
48
+ const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
49
+ if (!fs.existsSync(binary)) return false;
50
+ try {
51
+ const fd = fs.openSync(binary, "r");
52
+ const buf = Buffer.alloc(4);
53
+ fs.readSync(fd, buf, 0, 4, 0);
54
+ fs.closeSync(fd);
55
+ const magic = buf.toString("hex");
56
+ if (process.platform === "linux") return magic.startsWith("7f454c46");
57
+ if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
58
+ if (process.platform === "win32") return magic.startsWith("4d5a");
59
+ return true;
60
+ } catch { return false; }
61
+ }
62
+
63
+ // Extract a short, user-friendly reason from npm stderr.
64
+ function summarizeNpmError(stderr = "") {
65
+ const text = String(stderr);
66
+ if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable";
67
+ if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied (check folder permissions)";
68
+ if (/ENOSPC|no space/i.test(text)) return "Not enough disk space";
69
+ if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)";
70
+ if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry";
71
+ const m = text.match(/npm ERR! (.+)/);
72
+ if (m) return m[1].slice(0, 200);
73
+ const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop();
74
+ return lastLine ? lastLine.slice(0, 200) : "Unknown error";
75
+ }
76
+
77
+ function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) {
78
+ const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs];
79
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
80
+ const res = spawnSync(npmCmd, args, {
81
+ cwd,
82
+ stdio: ["ignore", "pipe", "pipe"],
83
+ timeout,
84
+ shell: process.platform === "win32",
85
+ encoding: "utf8",
86
+ });
87
+ return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" };
88
+ }
89
+
90
+ function npmInstall(pkgs, opts = {}) {
91
+ const cwd = ensureRuntimeDir();
92
+ const extra = opts.optional ? ["--no-save"] : [];
93
+ if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)...");
94
+ const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 });
95
+ if (!res.ok && !opts.silent) {
96
+ const reason = summarizeNpmError(res.stderr);
97
+ console.warn("⚠️ SQLite engine install failed — using fallback");
98
+ console.warn(` Reason: ${reason}`);
99
+ console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
100
+ }
101
+ return res.ok;
102
+ }
103
+
104
+ // Public: ensure better-sqlite3 native module is installed in user-writable
105
+ // runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in.
106
+ // This is purely a *speed optimization* — app works without it via fallbacks.
107
+ function ensureSqliteRuntime({ silent = false } = {}) {
108
+ ensureRuntimeDir();
109
+
110
+ const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
111
+ if (!needBetterSqlite) {
112
+ if (!silent) console.log("✅ SQLite engine ready");
113
+ return { betterSqlite: true };
114
+ }
115
+
116
+ const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
117
+ return {
118
+ betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
119
+ };
120
+ }
121
+
122
+ // Inject runtime + bundled node_modules into NODE_PATH so child Node processes
123
+ // resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime).
124
+ function buildEnvWithRuntime(baseEnv = process.env) {
125
+ const runtimeNm = getRuntimeNodeModules();
126
+ const bundledNm = path.join(__dirname, "..", "app", "node_modules");
127
+ const existing = baseEnv.NODE_PATH || "";
128
+ const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter);
129
+ return { ...baseEnv, NODE_PATH };
130
+ }
131
+
132
+ module.exports = {
133
+ ensureSqliteRuntime,
134
+ buildEnvWithRuntime,
135
+ getRuntimeDir,
136
+ getRuntimeNodeModules,
137
+ runNpmInstall,
138
+ summarizeNpmError,
139
+ };
cli/hooks/trayRuntime.js ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules.
2
+ // Windows uses PowerShell NotifyIcon (no binary) → no systray needed.
3
+ // This keeps the published npm tarball free of unsigned Go binaries that
4
+ // trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe).
5
+ //
6
+ // We use the maintained `systray2` fork. The original `systray@1.0.5` package
7
+ // bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern
8
+ // dyld (macOS 14+), so the tray silently fails to register on Apple Silicon.
9
+ const { spawnSync } = require("child_process");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime");
13
+
14
+ const SYSTRAY_PKG = "systray2";
15
+ const SYSTRAY_VERSION = "2.1.4";
16
+ const LEGACY_SYSTRAY_PKG = "systray";
17
+
18
+ function hasSystray() {
19
+ return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json"));
20
+ }
21
+
22
+ // Remove the legacy `systray` package from all known locations.
23
+ // On Windows it was an AV false-positive risk; on macOS/Linux its bundled
24
+ // binary is broken on modern OS versions.
25
+ function cleanupLegacySystray({ silent = false } = {}) {
26
+ // 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win)
27
+ // 2) npm global nested: <npm_prefix>/node_modules/9router/node_modules/systray
28
+ // __dirname here = <pkg root>/hooks → up 1 = pkg root
29
+ const targets = [
30
+ path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG),
31
+ path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG)
32
+ ];
33
+ for (const dir of targets) {
34
+ if (fs.existsSync(dir)) {
35
+ try {
36
+ fs.rmSync(dir, { recursive: true, force: true });
37
+ if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`);
38
+ } catch (e) {
39
+ if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ // systray2's npm tarball sometimes ships the bundled Go binary without the
46
+ // executable bit set on macOS, causing spawn() to fail with EACCES. Set +x
47
+ // best-effort so the tray actually starts.
48
+ function chmodSystrayBin({ silent = false } = {}) {
49
+ if (process.platform === "win32") return;
50
+ const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
51
+ const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName);
52
+ if (!fs.existsSync(binPath)) return;
53
+ try {
54
+ fs.chmodSync(binPath, 0o755);
55
+ } catch (e) {
56
+ if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`);
57
+ }
58
+ }
59
+
60
+ function ensureRuntimeDir() {
61
+ const dir = getRuntimeDir();
62
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
63
+ const pkgPath = path.join(dir, "package.json");
64
+ if (!fs.existsSync(pkgPath)) {
65
+ fs.writeFileSync(pkgPath, JSON.stringify({
66
+ name: "9router-runtime",
67
+ version: "1.0.0",
68
+ private: true
69
+ }, null, 2));
70
+ }
71
+ return dir;
72
+ }
73
+
74
+ function npmInstall(pkgs, { silent = false } = {}) {
75
+ const cwd = ensureRuntimeDir();
76
+ if (!silent) console.log("⏳ Installing system tray (first run)...");
77
+ const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 });
78
+ if (!res.ok && !silent) {
79
+ const reason = summarizeNpmError(res.stderr);
80
+ console.warn("⚠️ System tray install failed — tray disabled");
81
+ console.warn(` Reason: ${reason}`);
82
+ console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
83
+ }
84
+ return res.ok;
85
+ }
86
+
87
+ // Public: ensure systray2 is installed on macOS/Linux only.
88
+ // Windows skips entirely (uses PowerShell tray).
89
+ function ensureTrayRuntime({ silent = false } = {}) {
90
+ // Always evict the legacy `systray` package — its binary is broken on
91
+ // modern macOS and an AV false-positive on Windows.
92
+ cleanupLegacySystray({ silent });
93
+
94
+ if (process.platform === "win32") {
95
+ return { systray: false, skipped: true };
96
+ }
97
+ if (hasSystray()) {
98
+ chmodSystrayBin({ silent });
99
+ if (!silent) console.log("✅ System tray ready");
100
+ return { systray: true };
101
+ }
102
+ const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent });
103
+ if (ok) chmodSystrayBin({ silent });
104
+ return { systray: ok && hasSystray() };
105
+ }
106
+
107
+ module.exports = { ensureTrayRuntime };
cli/package.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "9router",
3
+ "version": "0.5.4",
4
+ "description": "9Router CLI - Start and manage 9Router server",
5
+ "bin": {
6
+ "9router": "./cli.js"
7
+ },
8
+ "files": [
9
+ "cli.js",
10
+ "src",
11
+ "hooks",
12
+ "app",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js",
18
+ "build": "node scripts/build-cli.js",
19
+ "pack:cli": "npm run build && npm pack --pack-destination ../..",
20
+ "publish:cli": "npm run build && npm publish",
21
+ "postinstall": "node hooks/postinstall.js",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "dependencies": {
25
+ "enquirer": "^2.4.1",
26
+ "node-forge": "^1.3.3",
27
+ "node-machine-id": "^1.1.12",
28
+ "react": "19.2.1",
29
+ "react-dom": "19.2.1"
30
+ },
31
+ "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
32
+ "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "keywords": [
37
+ "9router",
38
+ "cli",
39
+ "proxy",
40
+ "ai",
41
+ "api"
42
+ ],
43
+ "license": "MIT",
44
+ "devDependencies": {
45
+ "esbuild": "^0.25.12",
46
+ "nodemon": "^3.1.14"
47
+ }
48
+ }
cli/scripts/build-cli.js ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execSync } = require("child_process");
6
+
7
+ const cliDir = path.resolve(__dirname, "..");
8
+ const appDir = path.resolve(cliDir, "..");
9
+ const rootDir = path.resolve(appDir, "..");
10
+ const cliAppDir = path.join(cliDir, "app");
11
+ const buildHomeDir = path.join(cliDir, ".build-home");
12
+ const buildDistDirName = ".next-cli-build";
13
+ const buildDistDir = path.join(appDir, buildDistDirName);
14
+
15
+ // Exclude patterns for files/folders we don't want to copy
16
+ const EXCLUDE_PATTERNS = [
17
+ "@img", // Sharp image processing (not needed with unoptimized images)
18
+ "sharp", // Sharp core lib (not needed with unoptimized images)
19
+ "detect-libc", // Sharp dependency
20
+ ".env", // Environment files
21
+ ".env.local",
22
+ ".env.*.local",
23
+ "*.log", // Log files
24
+ "tmp", // Temp files
25
+ ".DS_Store", // macOS files
26
+ ];
27
+
28
+ function shouldExclude(name) {
29
+ return EXCLUDE_PATTERNS.some(pattern => {
30
+ if (pattern.includes("*")) {
31
+ const regex = new RegExp("^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
32
+ return regex.test(name);
33
+ }
34
+ return name === pattern;
35
+ });
36
+ }
37
+
38
+ function copyRecursive(src, dest) {
39
+ if (!fs.existsSync(src)) {
40
+ console.warn(`Warning: Source ${src} does not exist`);
41
+ return;
42
+ }
43
+
44
+ if (!fs.existsSync(dest)) {
45
+ fs.mkdirSync(dest, { recursive: true });
46
+ }
47
+
48
+ const entries = fs.readdirSync(src, { withFileTypes: true });
49
+ for (const entry of entries) {
50
+ if (shouldExclude(entry.name)) {
51
+ continue;
52
+ }
53
+
54
+ const srcPath = path.join(src, entry.name);
55
+ const destPath = path.join(dest, entry.name);
56
+
57
+ // Skip broken symlinks (common in workspace setups)
58
+ try {
59
+ fs.accessSync(srcPath);
60
+ } catch {
61
+ continue;
62
+ }
63
+
64
+ if (entry.isDirectory()) {
65
+ copyRecursive(srcPath, destPath);
66
+ } else if (entry.isSymbolicLink()) {
67
+ // Resolve and copy target (avoid linking outside bundle)
68
+ try {
69
+ const real = fs.realpathSync(srcPath);
70
+ if (fs.statSync(real).isDirectory()) {
71
+ copyRecursive(real, destPath);
72
+ } else {
73
+ fs.copyFileSync(real, destPath);
74
+ }
75
+ } catch {}
76
+ } else {
77
+ try {
78
+ fs.copyFileSync(srcPath, destPath);
79
+ } catch {}
80
+ }
81
+ }
82
+ }
83
+
84
+ console.log("📦 Building 9Router CLI package with Next.js...\n");
85
+
86
+ fs.mkdirSync(buildHomeDir, { recursive: true });
87
+ fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true });
88
+ fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true });
89
+
90
+ // Step 0: Sync version from app/cli/package.json to app/package.json
91
+ console.log("0️⃣ Syncing version to app/package.json...");
92
+ const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8"));
93
+ const appPkgPath = path.join(appDir, "package.json");
94
+ const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
95
+ if (appPkg.version !== cliPkg.version) {
96
+ appPkg.version = cliPkg.version;
97
+ fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
98
+ console.log(`✅ Version synced: ${cliPkg.version}\n`);
99
+ } else {
100
+ console.log(`✅ Version already synced: ${cliPkg.version}\n`);
101
+ }
102
+
103
+ // Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone).
104
+ console.log("1️⃣ Building Next.js app...");
105
+ try {
106
+ execSync("npm run build", {
107
+ stdio: "inherit",
108
+ cwd: appDir,
109
+ env: {
110
+ ...process.env,
111
+ HOME: buildHomeDir,
112
+ USERPROFILE: buildHomeDir,
113
+ APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
114
+ LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
115
+ NEXT_DIST_DIR: buildDistDirName,
116
+ NEXT_TRACING_ROOT_MODE: "workspace",
117
+ }
118
+ });
119
+ console.log("✅ Next.js build completed\n");
120
+ } catch (error) {
121
+ console.error("❌ Next.js build failed");
122
+ process.exit(1);
123
+ }
124
+
125
+ // Step 2: Clean old app/cli/app if exists
126
+ console.log("2️⃣ Cleaning old app/cli/app...");
127
+ if (fs.existsSync(cliAppDir)) {
128
+ fs.rmSync(cliAppDir, { recursive: true, force: true });
129
+ }
130
+ console.log("✅ Cleaned\n");
131
+
132
+ // Step 3: Copy Next.js standalone build to app/cli/app.
133
+ // Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
134
+ // node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
135
+ console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
136
+ const standaloneRoot = path.join(appDir, ".next", "standalone");
137
+ const standaloneRootResolved = path.join(buildDistDir, "standalone");
138
+ const standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
139
+ const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
140
+ ? standaloneRootToUse
141
+ : path.join(standaloneRootToUse, "app");
142
+ if (!fs.existsSync(standaloneApp)) {
143
+ console.error("❌ Next.js standalone build not found under .next/standalone");
144
+ console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
145
+ process.exit(1);
146
+ }
147
+ copyRecursive(standaloneApp, cliAppDir);
148
+
149
+ // Older nested-app layout stores traced node_modules at standalone root.
150
+ const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules");
151
+ if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) {
152
+ copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
153
+ }
154
+ console.log("✅ Copied standalone build\n");
155
+
156
+ // Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF).
157
+ const customServerSrc = path.join(appDir, "custom-server.js");
158
+ if (fs.existsSync(customServerSrc)) {
159
+ fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
160
+ console.log("✅ Copied custom-server.js\n");
161
+ } else {
162
+ console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
163
+ }
164
+
165
+ // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
166
+ // Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid
167
+ // Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also
168
+ // available as a no-install middle tier.
169
+ console.log("3️⃣ b Configuring SQLite drivers...");
170
+ function ensureModuleInBundle(pkg) {
171
+ const dest = path.join(cliAppDir, "node_modules", pkg);
172
+ if (fs.existsSync(dest)) {
173
+ console.log(`✅ ${pkg} already bundled`);
174
+ return;
175
+ }
176
+ const candidates = [
177
+ path.join(appDir, "node_modules", pkg),
178
+ path.join(rootDir, "node_modules", pkg),
179
+ ];
180
+ const src = candidates.find((p) => fs.existsSync(p));
181
+ if (!src) {
182
+ console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`);
183
+ return;
184
+ }
185
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
186
+ copyRecursive(src, dest);
187
+ console.log(`✅ Bundled ${pkg}`);
188
+ }
189
+ ensureModuleInBundle("sql.js");
190
+ const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3");
191
+ if (fs.existsSync(betterDir)) {
192
+ fs.rmSync(betterDir, { recursive: true, force: true });
193
+ console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)");
194
+ }
195
+ console.log("");
196
+
197
+ // Step 4: Copy static files
198
+ console.log("4️⃣ Copying static files...");
199
+ const staticSrc = path.join(appDir, ".next", "static");
200
+ const staticSrcResolved = path.join(buildDistDir, "static");
201
+ const staticDest = path.join(cliAppDir, buildDistDirName, "static");
202
+ if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) {
203
+ copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest);
204
+ console.log("✅ Copied static files\n");
205
+ } else {
206
+ console.log("⏭️ No static files found\n");
207
+ }
208
+
209
+ // Step 5: Copy public folder if exists
210
+ console.log("5️⃣ Copying public folder...");
211
+ const publicSrc = path.join(appDir, "public");
212
+ const publicDest = path.join(cliAppDir, "public");
213
+ if (fs.existsSync(publicSrc)) {
214
+ copyRecursive(publicSrc, publicDest);
215
+ console.log("✅ Copied public folder\n");
216
+ } else {
217
+ console.log("⏭️ No public folder found\n");
218
+ }
219
+
220
+ // Step 6: Copy vendor-chunks (required for production)
221
+ console.log("6️⃣ Copying vendor-chunks...");
222
+ const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks");
223
+ const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks");
224
+ const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks");
225
+ if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
226
+ copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest);
227
+ console.log("✅ Copied vendor-chunks\n");
228
+ } else {
229
+ console.log("⏭️ No vendor-chunks found\n");
230
+ }
231
+
232
+ // Step 7: Copy MITM server files (not bundled by Next.js standalone)
233
+ console.log("7️⃣ Copying MITM server files...");
234
+ const mitmSrc = path.join(appDir, "src", "mitm");
235
+ const mitmDest = path.join(cliAppDir, "src", "mitm");
236
+ if (fs.existsSync(mitmSrc)) {
237
+ copyRecursive(mitmSrc, mitmDest);
238
+ console.log("✅ Copied MITM files\n");
239
+ } else {
240
+ console.log("⏭️ No MITM files found\n");
241
+ }
242
+
243
+ // Step 7b: Copy standalone updater (headless Node process for install progress)
244
+ console.log("7️⃣ b Copying updater files...");
245
+ const updaterSrc = path.join(appDir, "src", "lib", "updater");
246
+ const updaterDest = path.join(cliAppDir, "src", "lib", "updater");
247
+ if (fs.existsSync(updaterSrc)) {
248
+ copyRecursive(updaterSrc, updaterDest);
249
+ console.log("✅ Copied updater files\n");
250
+ } else {
251
+ console.log("⏭️ No updater files found\n");
252
+ }
253
+
254
+ // Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
255
+ console.log("8️⃣ Building MITM server...");
256
+ try {
257
+ execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir });
258
+ console.log("✅ MITM server build completed\n");
259
+ } catch (error) {
260
+ console.error("❌ MITM build failed");
261
+ process.exit(1);
262
+ }
263
+
264
+ console.log("✨ CLI package build completed!");
265
+ console.log(`📁 Output: ${cliAppDir}`);
266
+
267
+ try {
268
+ const { execSync: exec } = require("child_process");
269
+ const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim();
270
+ console.log(`📊 Package size: ${size.split("\t")[0]}`);
271
+ } catch (e) {
272
+ // Silent fail on size check
273
+ }
cli/scripts/buildMitm.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const esbuild = require("esbuild");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ // ── Build config ─────────────────────────────────────────
6
+ const BUILD_CONFIG = {
7
+ bundle: true,
8
+ minify: true,
9
+ cleanPlainFiles: true,
10
+ };
11
+ // ─────────────────────────────────────────────────────────
12
+
13
+ const cliDir = path.resolve(__dirname, "..");
14
+ const appDir = path.resolve(cliDir, "..");
15
+ const cliMitmDir = path.join(cliDir, "app", "src", "mitm");
16
+ // Bundle everything — no externals. This keeps MITM runtime self-contained so
17
+ // it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
18
+ // node_modules file locks that block `npm i -g 9router@latest` on Windows).
19
+ const EXTERNALS = [];
20
+ const ENTRIES = ["server.js"];
21
+
22
+ async function buildEntry(entry) {
23
+ const mitmSrc = path.join(appDir, "src", "mitm");
24
+ const output = path.join(cliMitmDir, entry);
25
+
26
+ const buildPlugin = {
27
+ name: "build-plugin",
28
+ setup(build) {
29
+ // Stub .git file scanned by esbuild
30
+ build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" }));
31
+ build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" }));
32
+ },
33
+ };
34
+
35
+ const steps = [];
36
+
37
+ if (BUILD_CONFIG.bundle) {
38
+ await esbuild.build({
39
+ entryPoints: [path.join(mitmSrc, entry)],
40
+ bundle: true,
41
+ minify: BUILD_CONFIG.minify,
42
+ platform: "node",
43
+ target: "node18",
44
+ external: EXTERNALS,
45
+ plugins: [buildPlugin],
46
+ outfile: output,
47
+ });
48
+ steps.push("bundled");
49
+ if (BUILD_CONFIG.minify) steps.push("minified");
50
+ }
51
+
52
+ console.log(`✅ ${steps.join(" + ")} → ${output}`);
53
+ }
54
+
55
+ async function run() {
56
+ const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", ");
57
+ console.log(`⚙️ Config: ${flags}`);
58
+
59
+ for (const entry of ENTRIES) await buildEntry(entry);
60
+
61
+ if (BUILD_CONFIG.cleanPlainFiles) {
62
+ const keep = new Set(ENTRIES);
63
+ for (const name of fs.readdirSync(cliMitmDir)) {
64
+ if (!keep.has(name)) fs.rmSync(path.join(cliMitmDir, name), { recursive: true, force: true });
65
+ }
66
+ console.log("✅ Removed plain MITM files from CLI bundle");
67
+ }
68
+ }
69
+
70
+ run().catch((e) => { console.error(e); process.exit(1); });
cli/src/cli/api/client.js ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const http = require("http");
2
+ const https = require("https");
3
+ const crypto = require("crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+ const { machineIdSync } = require("node-machine-id");
8
+
9
+ // Default configuration
10
+ const DEFAULT_CONFIG = {
11
+ host: "localhost",
12
+ port: 20128,
13
+ protocol: "http:",
14
+ };
15
+
16
+ const CLI_TOKEN_HEADER = "x-9r-cli-token";
17
+ const CLI_TOKEN_SALT = "9r-cli-auth";
18
+ const APP_NAME = "9router";
19
+
20
+ function getDataDir() {
21
+ if (process.env.DATA_DIR) return process.env.DATA_DIR;
22
+ if (process.platform === "win32") {
23
+ return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME);
24
+ }
25
+ return path.join(os.homedir(), `.${APP_NAME}`);
26
+ }
27
+
28
+ const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id");
29
+ const AUTH_DIR = path.join(getDataDir(), "auth");
30
+ const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret");
31
+
32
+ let config = { ...DEFAULT_CONFIG };
33
+ let cachedCliToken = null;
34
+ let cachedCliSecret = null;
35
+
36
+ // Read raw machineId from shared file (written by server) → guarantees token match
37
+ function loadRawMachineId() {
38
+ try {
39
+ const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim();
40
+ if (raw) return raw;
41
+ } catch {}
42
+ try { return machineIdSync(); } catch { return ""; }
43
+ }
44
+
45
+ // Random secret shared with server via file → token unpredictable from machineId alone.
46
+ function loadCliSecret() {
47
+ if (cachedCliSecret) return cachedCliSecret;
48
+ try {
49
+ cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim();
50
+ if (cachedCliSecret) return cachedCliSecret;
51
+ } catch {}
52
+ cachedCliSecret = crypto.randomBytes(32).toString("hex");
53
+ try {
54
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
55
+ fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 });
56
+ } catch {}
57
+ return cachedCliSecret;
58
+ }
59
+
60
+ function getCliToken() {
61
+ if (cachedCliToken !== null) return cachedCliToken;
62
+ const raw = loadRawMachineId();
63
+ const secret = loadCliSecret();
64
+ cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : "";
65
+ return cachedCliToken;
66
+ }
67
+
68
+ /**
69
+ * Configure API client
70
+ * @param {Object} options - Configuration options
71
+ * @param {string} options.host - API host
72
+ * @param {number} options.port - API port
73
+ * @param {string} options.protocol - Protocol (http: or https:)
74
+ */
75
+ function configure(options = {}) {
76
+ config = { ...config, ...options };
77
+ }
78
+
79
+ /**
80
+ * Make HTTP request to API
81
+ * @param {string} method - HTTP method
82
+ * @param {string} path - API path
83
+ * @param {Object} body - Request body (optional)
84
+ * @returns {Promise<Object>} Response with { success, data/error }
85
+ */
86
+ function makeRequest(method, path, body = null) {
87
+ return new Promise((resolve) => {
88
+ const httpModule = config.protocol === "https:" ? https : http;
89
+
90
+ const options = {
91
+ hostname: config.host,
92
+ port: config.port,
93
+ path: path,
94
+ method: method,
95
+ headers: {
96
+ "Content-Type": "application/json",
97
+ [CLI_TOKEN_HEADER]: getCliToken(),
98
+ },
99
+ };
100
+
101
+ // Add Content-Length for POST/PUT requests
102
+ if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
103
+ const bodyString = JSON.stringify(body);
104
+ options.headers["Content-Length"] = Buffer.byteLength(bodyString);
105
+ }
106
+
107
+ const req = httpModule.request(options, (res) => {
108
+ let data = "";
109
+
110
+ res.on("data", (chunk) => {
111
+ data += chunk;
112
+ });
113
+
114
+ res.on("end", () => {
115
+ try {
116
+ const parsed = data ? JSON.parse(data) : {};
117
+
118
+ // Check if response indicates error
119
+ if (res.statusCode >= 400 || parsed.error) {
120
+ resolve({
121
+ success: false,
122
+ error: parsed.error || `HTTP ${res.statusCode}`,
123
+ statusCode: res.statusCode,
124
+ });
125
+ } else {
126
+ resolve({
127
+ success: true,
128
+ data: parsed,
129
+ statusCode: res.statusCode,
130
+ });
131
+ }
132
+ } catch (err) {
133
+ resolve({
134
+ success: false,
135
+ error: `Failed to parse response: ${err.message}`,
136
+ });
137
+ }
138
+ });
139
+ });
140
+
141
+ req.on("error", (err) => {
142
+ resolve({
143
+ success: false,
144
+ error: `Network error: ${err.message}`,
145
+ });
146
+ });
147
+
148
+ req.on("timeout", () => {
149
+ req.destroy();
150
+ resolve({
151
+ success: false,
152
+ error: "Request timeout",
153
+ });
154
+ });
155
+
156
+ // Set timeout (30 seconds)
157
+ req.setTimeout(30000);
158
+
159
+ // Write body if present
160
+ if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
161
+ req.write(JSON.stringify(body));
162
+ }
163
+
164
+ req.end();
165
+ });
166
+ }
167
+
168
+ // ============================================================================
169
+ // PROVIDERS API
170
+ // ============================================================================
171
+
172
+ /**
173
+ * Get all providers
174
+ * @returns {Promise<Object>} { success, data: { connections } }
175
+ */
176
+ async function getProviders() {
177
+ return makeRequest("GET", "/api/providers");
178
+ }
179
+
180
+ /**
181
+ * Get provider by ID
182
+ * @param {string} id - Provider ID
183
+ * @returns {Promise<Object>} { success, data: { connection } }
184
+ */
185
+ async function getProviderById(id) {
186
+ return makeRequest("GET", `/api/providers/${id}`);
187
+ }
188
+
189
+ /**
190
+ * Test provider connection
191
+ * @param {string} id - Provider ID
192
+ * @returns {Promise<Object>} { success, data: { valid, error } }
193
+ */
194
+ async function testProvider(id) {
195
+ return makeRequest("POST", `/api/providers/${id}/test`);
196
+ }
197
+
198
+ /**
199
+ * Delete provider
200
+ * @param {string} id - Provider ID
201
+ * @returns {Promise<Object>} { success, data: { message } }
202
+ */
203
+ async function deleteProvider(id) {
204
+ return makeRequest("DELETE", `/api/providers/${id}`);
205
+ }
206
+
207
+ /**
208
+ * Get provider models
209
+ * @param {string} id - Provider ID
210
+ * @returns {Promise<Object>} { success, data: { provider, connectionId, models } }
211
+ */
212
+ async function getProviderModels(id) {
213
+ return makeRequest("GET", `/api/providers/${id}/models`);
214
+ }
215
+
216
+ // ============================================================================
217
+ // OAUTH API
218
+ // ============================================================================
219
+
220
+ /**
221
+ * Get OAuth authorization URL
222
+ * @param {string} provider - Provider ID
223
+ * @returns {Promise<Object>} { success, data: { authUrl, codeVerifier, state, redirectUri } }
224
+ */
225
+ async function getOAuthAuthUrl(provider) {
226
+ // Codex requires fixed port 1455 and path /auth/callback
227
+ const redirectUri = provider === "codex"
228
+ ? "http://localhost:1455/auth/callback"
229
+ : "http://localhost:20128/callback";
230
+ return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
231
+ }
232
+
233
+ /**
234
+ * Exchange OAuth authorization code for token
235
+ * @param {string} provider - Provider ID
236
+ * @param {Object} data - { code, redirectUri, codeVerifier, state }
237
+ * @returns {Promise<Object>} { success, data }
238
+ */
239
+ async function exchangeOAuthCode(provider, data) {
240
+ return makeRequest("POST", `/api/oauth/${provider}/exchange`, data);
241
+ }
242
+
243
+ /**
244
+ * Get OAuth device code
245
+ * @param {string} provider - Provider ID
246
+ * @returns {Promise<Object>} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } }
247
+ */
248
+ async function getOAuthDeviceCode(provider) {
249
+ return makeRequest("GET", `/api/oauth/${provider}/device-code`);
250
+ }
251
+
252
+ /**
253
+ * Poll OAuth token using device code
254
+ * @param {string} provider - Provider ID
255
+ * @param {Object} data - { deviceCode, codeVerifier, extraData }
256
+ * @returns {Promise<Object>} { success, data: { pending } }
257
+ */
258
+ async function pollOAuthToken(provider, data) {
259
+ return makeRequest("POST", `/api/oauth/${provider}/poll`, data);
260
+ }
261
+
262
+ /**
263
+ * Create API key provider connection
264
+ * @param {Object} data - { provider, name, apiKey }
265
+ * @returns {Promise<Object>} { success, data }
266
+ */
267
+ async function createApiKeyProvider(data) {
268
+ return makeRequest("POST", "/api/providers", data);
269
+ }
270
+
271
+ /**
272
+ * Update provider connection
273
+ * @param {string} id - Connection ID
274
+ * @param {Object} data - { name, priority, defaultModel, isActive }
275
+ * @returns {Promise<Object>} { success, data: { connection } }
276
+ */
277
+ async function updateConnection(id, data) {
278
+ return makeRequest("PUT", `/api/providers/${id}`, data);
279
+ }
280
+
281
+ // ============================================================================
282
+ // API KEYS API
283
+ // ============================================================================
284
+
285
+ /**
286
+ * Get all API keys
287
+ * @returns {Promise<Object>} { success, data: { keys } }
288
+ */
289
+ async function getApiKeys() {
290
+ return makeRequest("GET", "/api/keys");
291
+ }
292
+
293
+ /**
294
+ * Create new API key
295
+ * @param {string} name - Key name
296
+ * @returns {Promise<Object>} { success, data: { key, name, id, machineId } }
297
+ */
298
+ async function createApiKey(name) {
299
+ return makeRequest("POST", "/api/keys", { name });
300
+ }
301
+
302
+ /**
303
+ * Delete API key
304
+ * @param {string} id - Key ID
305
+ * @returns {Promise<Object>} { success, data: { success } }
306
+ */
307
+ async function deleteApiKey(id) {
308
+ return makeRequest("DELETE", `/api/keys/${id}`);
309
+ }
310
+
311
+ // ============================================================================
312
+ // COMBOS API
313
+ // ============================================================================
314
+
315
+ /**
316
+ * Get all combos
317
+ * @returns {Promise<Object>} { success, data: { combos } }
318
+ */
319
+ async function getCombos() {
320
+ return makeRequest("GET", "/api/combos");
321
+ }
322
+
323
+ /**
324
+ * Get combo by ID
325
+ * @param {string} id - Combo ID
326
+ * @returns {Promise<Object>} { success, data: combo }
327
+ */
328
+ async function getComboById(id) {
329
+ return makeRequest("GET", `/api/combos/${id}`);
330
+ }
331
+
332
+ /**
333
+ * Create new combo
334
+ * @param {Object} data - Combo data { name, models }
335
+ * @returns {Promise<Object>} { success, data: combo }
336
+ */
337
+ async function createCombo(data) {
338
+ return makeRequest("POST", "/api/combos", data);
339
+ }
340
+
341
+ /**
342
+ * Update combo
343
+ * @param {string} id - Combo ID
344
+ * @param {Object} data - Update data { name?, models? }
345
+ * @returns {Promise<Object>} { success, data: combo }
346
+ */
347
+ async function updateCombo(id, data) {
348
+ return makeRequest("PUT", `/api/combos/${id}`, data);
349
+ }
350
+
351
+ /**
352
+ * Delete combo
353
+ * @param {string} id - Combo ID
354
+ * @returns {Promise<Object>} { success, data: { success } }
355
+ */
356
+ async function deleteCombo(id) {
357
+ return makeRequest("DELETE", `/api/combos/${id}`);
358
+ }
359
+
360
+ // ============================================================================
361
+ // CLI TOOLS API
362
+ // ============================================================================
363
+
364
+ /**
365
+ * Get CLI tool settings
366
+ * @param {string} tool - Tool name: claude | codex | droid | openclaw
367
+ * @returns {Promise<Object>} { success, data: { installed, has9Router, ... } }
368
+ */
369
+ async function getCliToolSettings(tool) {
370
+ return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
371
+ }
372
+
373
+ /**
374
+ * Apply CLI tool settings (POST)
375
+ * @param {string} tool - Tool name: claude | codex | droid | openclaw
376
+ * @param {Object} body - Payload depends on tool
377
+ * @returns {Promise<Object>} { success, data }
378
+ */
379
+ async function applyCliToolSettings(tool, body) {
380
+ return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
381
+ }
382
+
383
+ /**
384
+ * Reset CLI tool settings (DELETE)
385
+ * @param {string} tool - Tool name: claude | codex | droid | openclaw
386
+ * @returns {Promise<Object>} { success, data }
387
+ */
388
+ async function resetCliToolSettings(tool) {
389
+ return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
390
+ }
391
+
392
+ // ============================================================================
393
+ // SETTINGS API
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Get settings
398
+ * @returns {Promise<Object>} { success, data: settings }
399
+ */
400
+ async function getSettings() {
401
+ return makeRequest("GET", "/api/settings");
402
+ }
403
+
404
+ /**
405
+ * Update settings
406
+ * @param {Object} data - Settings data
407
+ * @returns {Promise<Object>} { success, data: settings }
408
+ */
409
+ async function updateSettings(data) {
410
+ return makeRequest("PATCH", "/api/settings", data);
411
+ }
412
+
413
+ /**
414
+ * Reset dashboard password to default (clears stored hash server-side)
415
+ * @returns {Promise<Object>} { success }
416
+ */
417
+ async function resetPassword() {
418
+ return makeRequest("POST", "/api/auth/reset-password");
419
+ }
420
+
421
+ // ============================================================================
422
+ // MODELS API
423
+ // ============================================================================
424
+
425
+ /**
426
+ * Get all models (internal API)
427
+ * @returns {Promise<Object>} { success, data: { models } }
428
+ */
429
+ async function getModels() {
430
+ return makeRequest("GET", "/api/models");
431
+ }
432
+
433
+ /**
434
+ * Get available models from active providers + combos (OpenAI compatible)
435
+ * @returns {Promise<Object>} { success, data: { object, data: [...models] } }
436
+ */
437
+ async function getAvailableModels() {
438
+ return makeRequest("GET", "/v1/models");
439
+ }
440
+
441
+ // ============================================================================
442
+ // PROVIDER NODES API (custom providers)
443
+ // ============================================================================
444
+
445
+ async function getProviderNodes() {
446
+ return makeRequest("GET", "/api/provider-nodes");
447
+ }
448
+
449
+ async function createProviderNode(data) {
450
+ return makeRequest("POST", "/api/provider-nodes", data);
451
+ }
452
+
453
+ async function updateProviderNode(id, data) {
454
+ return makeRequest("PUT", `/api/provider-nodes/${id}`, data);
455
+ }
456
+
457
+ async function deleteProviderNode(id) {
458
+ return makeRequest("DELETE", `/api/provider-nodes/${id}`);
459
+ }
460
+
461
+ async function validateProviderNode(data) {
462
+ return makeRequest("POST", "/api/provider-nodes/validate", data);
463
+ }
464
+
465
+ // ============================================================================
466
+ // TUNNEL API
467
+ // ============================================================================
468
+
469
+ /**
470
+ * Get tunnel status
471
+ * @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
472
+ */
473
+ async function getTunnelStatus() {
474
+ return makeRequest("GET", "/api/tunnel/status");
475
+ }
476
+
477
+ /**
478
+ * Enable tunnel
479
+ * @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
480
+ */
481
+ async function enableTunnel() {
482
+ return makeRequest("POST", "/api/tunnel/enable");
483
+ }
484
+
485
+ /**
486
+ * Disable tunnel
487
+ * @returns {Promise<Object>} { success, data: { success } }
488
+ */
489
+ async function disableTunnel() {
490
+ return makeRequest("POST", "/api/tunnel/disable");
491
+ }
492
+
493
+ // ============================================================================
494
+ // EXPORTS
495
+ // ============================================================================
496
+
497
+ module.exports = {
498
+ configure,
499
+
500
+ // Providers
501
+ getProviders,
502
+ getProviderById,
503
+ testProvider,
504
+ deleteProvider,
505
+ getProviderModels,
506
+
507
+ // Connection aliases
508
+ testConnection: testProvider,
509
+ deleteConnection: deleteProvider,
510
+ updateConnection,
511
+
512
+ // OAuth
513
+ getOAuthAuthUrl,
514
+ exchangeOAuthCode,
515
+ getOAuthDeviceCode,
516
+ pollOAuthToken,
517
+ createApiKeyProvider,
518
+
519
+ // API Keys
520
+ getApiKeys,
521
+ createApiKey,
522
+ deleteApiKey,
523
+
524
+ // Combos
525
+ getCombos,
526
+ getComboById,
527
+ createCombo,
528
+ updateCombo,
529
+ deleteCombo,
530
+
531
+ // CLI Tools
532
+ getCliToolSettings,
533
+ applyCliToolSettings,
534
+ resetCliToolSettings,
535
+
536
+ // Settings
537
+ getSettings,
538
+ updateSettings,
539
+ resetPassword,
540
+
541
+ // Tunnel
542
+ getTunnelStatus,
543
+ enableTunnel,
544
+ disableTunnel,
545
+
546
+ // Models
547
+ getModels,
548
+ getAvailableModels,
549
+
550
+ // Provider Nodes (custom providers)
551
+ getProviderNodes,
552
+ createProviderNode,
553
+ updateProviderNode,
554
+ deleteProviderNode,
555
+ validateProviderNode,
556
+ };
cli/src/cli/menus/apiKeys.js ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+ const { prompt, confirm, pause } = require("../utils/input");
3
+ const { clearScreen, showStatus, showHeader } = require("../utils/display");
4
+ const { maskKey, formatDate, getRelativeTime } = require("../utils/format");
5
+ const { showMenuWithBack } = require("../utils/menuHelper");
6
+ const { copyToClipboard } = require("../utils/clipboard");
7
+ const { getEndpoint } = require("../utils/endpoint");
8
+
9
+ /**
10
+ * Display API keys list with formatted output
11
+ * @param {Array} keys - Array of API key objects
12
+ * @param {number} port - Server port
13
+ */
14
+ function displayApiKeys(keys, port) {
15
+ console.log("┌─────────────────────────────────────────────────────────┐");
16
+ console.log("│ 🔑 API Keys Management │");
17
+ console.log("├─────────────────────────────────────────────────────────┤");
18
+ // Note: This function is legacy, endpoint shown in menu header instead
19
+ console.log("│ │");
20
+
21
+ if (keys.length === 0) {
22
+ console.log("│ No API keys found. │");
23
+ } else {
24
+ console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}│`);
25
+
26
+ keys.forEach((key, index) => {
27
+ console.log("│ │");
28
+ console.log(`│ ${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}│`);
29
+
30
+ const maskedKey = maskKey(key.key);
31
+ console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}│`);
32
+
33
+ const created = formatDate(key.createdAt);
34
+ console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}│`);
35
+
36
+ if (key.lastUsedAt) {
37
+ const lastUsed = getRelativeTime(key.lastUsedAt);
38
+ console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}│`);
39
+ } else {
40
+ console.log("│ Last used: Never │");
41
+ }
42
+ });
43
+ }
44
+
45
+ console.log("│ │");
46
+ console.log("│ Actions: │");
47
+ console.log("│ 1. Create New API Key │");
48
+ console.log("│ 2. View Full Key (by number) │");
49
+ console.log("│ 3. Copy Key to Clipboard (by number) │");
50
+ console.log("│ 4. Delete Key (by number) │");
51
+ console.log("│ 0. ← Back to Main Menu │");
52
+ console.log("└─────────────────────────────────────────────────────────┘");
53
+ }
54
+
55
+ /**
56
+ * Handle creating new API key
57
+ * @returns {Promise<boolean>} Success status
58
+ */
59
+ async function handleCreateKey() {
60
+ console.log("\n📝 Create New API Key");
61
+ console.log("─".repeat(30));
62
+
63
+ const name = await prompt("Enter key name: ");
64
+
65
+ if (!name) {
66
+ showStatus("Key name cannot be empty", "error");
67
+ await pause();
68
+ return false;
69
+ }
70
+
71
+ const result = await api.createApiKey(name);
72
+
73
+ if (!result.success) {
74
+ showStatus(`Failed to create key: ${result.error}`, "error");
75
+ await pause();
76
+ return false;
77
+ }
78
+
79
+ console.log("\n✅ API Key created successfully!");
80
+ console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!");
81
+ console.log(`\nKey: ${result.data.key}`);
82
+ console.log(`Name: ${result.data.name}`);
83
+ console.log(`ID: ${result.data.id}`);
84
+
85
+ const shouldCopy = await confirm("\nCopy key to clipboard?");
86
+ if (shouldCopy) {
87
+ if (copyToClipboard(result.data.key)) {
88
+ showStatus("Key copied to clipboard!", "success");
89
+ } else {
90
+ showStatus("Failed to copy to clipboard", "error");
91
+ }
92
+ }
93
+
94
+ await pause();
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * Handle viewing full API key
100
+ * @param {Object} key - API key object
101
+ */
102
+ async function handleViewFullKey(key) {
103
+ console.log("\n🔍 Full API Key");
104
+ console.log("─".repeat(30));
105
+ console.log(`Name: ${key.name}`);
106
+ console.log(`Key: ${key.key}`);
107
+ console.log(`ID: ${key.id}`);
108
+ console.log(`Created: ${formatDate(key.createdAt)}`);
109
+
110
+ if (key.lastUsedAt) {
111
+ console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`);
112
+ } else {
113
+ console.log("Last used: Never");
114
+ }
115
+
116
+ await pause();
117
+ }
118
+
119
+ /**
120
+ * Handle copying API key to clipboard
121
+ * @param {Object} key - API key object
122
+ */
123
+ async function handleCopyKey(key) {
124
+ if (copyToClipboard(key.key)) {
125
+ showStatus(`Key "${key.name}" copied to clipboard!`, "success");
126
+ } else {
127
+ showStatus("Failed to copy to clipboard", "error");
128
+ }
129
+ await pause();
130
+ }
131
+
132
+ /**
133
+ * Handle deleting API key
134
+ * @param {Object} key - API key object
135
+ * @returns {Promise<boolean>} Success status
136
+ */
137
+ async function handleDeleteKey(key) {
138
+ console.log(`\n⚠️ Delete API Key: ${key.name}`);
139
+ console.log("─".repeat(30));
140
+ console.log(`Key: ${maskKey(key.key)}`);
141
+ console.log(`Created: ${formatDate(key.createdAt)}`);
142
+
143
+ const confirmed = await confirm("\nAre you sure you want to delete this key?");
144
+
145
+ if (!confirmed) {
146
+ showStatus("Deletion cancelled", "info");
147
+ await pause();
148
+ return false;
149
+ }
150
+
151
+ const result = await api.deleteApiKey(key.id);
152
+
153
+ if (!result.success) {
154
+ showStatus(`Failed to delete key: ${result.error}`, "error");
155
+ await pause();
156
+ return false;
157
+ }
158
+
159
+ showStatus("API key deleted successfully", "success");
160
+ await pause();
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Show actions for a specific key
166
+ * @param {Object} key - API key object
167
+ * @param {number} port - Server port
168
+ * @param {Array<string>} breadcrumb - Breadcrumb path
169
+ */
170
+ async function showKeyActions(key, port, breadcrumb = []) {
171
+ const { endpoint } = await getEndpoint(port);
172
+ await showMenuWithBack({
173
+ title: `🔑 ${key.name}`,
174
+ breadcrumb: [...breadcrumb, key.name],
175
+ headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`,
176
+ items: [
177
+ {
178
+ label: "Copy to Clipboard",
179
+ action: async () => {
180
+ await handleCopyKey(key);
181
+ return true;
182
+ }
183
+ },
184
+ {
185
+ label: "Delete Key",
186
+ action: async () => {
187
+ await handleDeleteKey(key);
188
+ return false; // Exit after delete
189
+ }
190
+ }
191
+ ]
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Main API Keys menu
197
+ * @param {number} port - Server port number
198
+ * @param {Array<string>} breadcrumb - Breadcrumb path
199
+ */
200
+ async function showApiKeysMenu(port, breadcrumb = []) {
201
+ const { showListMenu } = require("../utils/menuHelper");
202
+
203
+ const { endpoint } = await getEndpoint(port);
204
+ await showListMenu({
205
+ title: "🔑 API Keys Management",
206
+ breadcrumb,
207
+ headerContent: `Endpoint: ${endpoint}`,
208
+ fetchItems: async () => {
209
+ const result = await api.getApiKeys();
210
+ if (!result.success) {
211
+ clearScreen();
212
+ showStatus(`Failed to fetch API keys: ${result.error}`, "error");
213
+ await pause();
214
+ return null;
215
+ }
216
+ return { items: result.data.keys || [] };
217
+ },
218
+ formatItem: (key) => `${key.name} (${maskKey(key.key)})`,
219
+ onSelect: async (key) => {
220
+ await showKeyActions(key, port, breadcrumb);
221
+ },
222
+ createAction: {
223
+ label: "Create New API Key",
224
+ action: async () => {
225
+ await handleCreateKey();
226
+ }
227
+ }
228
+ });
229
+ }
230
+
231
+ module.exports = {
232
+ showApiKeysMenu
233
+ };
cli/src/cli/menus/cliTools.js ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+ const { pause, confirm } = require("../utils/input");
3
+ const { showStatus } = require("../utils/display");
4
+ const { selectModelFromList } = require("../utils/modelSelector");
5
+ const { showMenuWithBack } = require("../utils/menuHelper");
6
+ const { getEndpoint } = require("../utils/endpoint");
7
+
8
+ const COLORS = {
9
+ reset: "\x1b[0m",
10
+ green: "\x1b[32m",
11
+ red: "\x1b[31m",
12
+ dim: "\x1b[2m",
13
+ cyan: "\x1b[36m"
14
+ };
15
+
16
+ // Claude model types with defaults (matching Web UI)
17
+ const CLAUDE_MODEL_TYPES = [
18
+ { id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
19
+ { id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
20
+ { id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
21
+ ];
22
+
23
+ // ─── Shared helpers ───────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Get first available API key from server
27
+ * @returns {Promise<string|null>}
28
+ */
29
+ async function getFirstApiKey() {
30
+ const result = await api.getApiKeys();
31
+ const keys = result.success ? (result.data.keys || []) : [];
32
+ return keys.length > 0 ? keys[0].key : null;
33
+ }
34
+
35
+ // ─── Claude Code ──────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Build header showing current Claude config status
39
+ * @returns {Promise<string>}
40
+ */
41
+ async function buildClaudeHeader() {
42
+ const result = await api.getCliToolSettings("claude");
43
+ if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
44
+
45
+ const settings = result.data.settings;
46
+ const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
47
+ const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
48
+ const lines = [];
49
+
50
+ if (currentUrl) {
51
+ lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
52
+ lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
53
+ if (currentKey) {
54
+ lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
55
+ }
56
+ } else {
57
+ lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
58
+ lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
59
+ }
60
+
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * Get current Claude model from settings
66
+ * @param {string} envKey
67
+ * @returns {Promise<string>}
68
+ */
69
+ async function getClaudeModel(envKey) {
70
+ const result = await api.getCliToolSettings("claude");
71
+ return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
72
+ }
73
+
74
+ /**
75
+ * Quick setup for Claude Code — sets endpoint, key, and all default models
76
+ * @param {number} port
77
+ */
78
+ async function claudeQuickSetup(port) {
79
+ const { endpoint } = await getEndpoint(port);
80
+ const apiKey = await getFirstApiKey();
81
+
82
+ if (!apiKey) {
83
+ showStatus("No API keys found. Create one in API Keys menu first.", "error");
84
+ await pause();
85
+ return;
86
+ }
87
+
88
+ const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
89
+ CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
90
+
91
+ const result = await api.applyCliToolSettings("claude", { env });
92
+ showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
93
+ await pause();
94
+ }
95
+
96
+ /**
97
+ * Select and save a specific Claude model type
98
+ * @param {Object} modelType
99
+ * @param {number} port
100
+ */
101
+ async function claudeSelectModel(modelType, port) {
102
+ const current = await getClaudeModel(modelType.envKey);
103
+ const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
104
+ if (!selected) return;
105
+
106
+ const env = { [modelType.envKey]: selected };
107
+
108
+ // Also set base URL if not configured yet
109
+ const settingsResult = await api.getCliToolSettings("claude");
110
+ if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
111
+ const { endpoint } = await getEndpoint(port);
112
+ const apiKey = await getFirstApiKey();
113
+ env.ANTHROPIC_BASE_URL = endpoint;
114
+ env.API_TIMEOUT_MS = "600000";
115
+ if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
116
+ }
117
+
118
+ const result = await api.applyCliToolSettings("claude", { env });
119
+ showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
120
+ await pause();
121
+ }
122
+
123
+ /**
124
+ * Reset Claude Code settings
125
+ */
126
+ async function claudeReset() {
127
+ const result = await api.resetCliToolSettings("claude");
128
+ showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
129
+ await pause();
130
+ }
131
+
132
+ /**
133
+ * Claude Code submenu
134
+ * @param {number} port
135
+ * @param {Array<string>} breadcrumb
136
+ */
137
+ async function showClaudeCodeMenu(port, breadcrumb = []) {
138
+ await showMenuWithBack({
139
+ title: "🔧 Claude Code Settings",
140
+ breadcrumb,
141
+ headerContent: buildClaudeHeader,
142
+ refresh: async () => ({
143
+ sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
144
+ opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
145
+ haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
146
+ }),
147
+ items: [
148
+ {
149
+ label: "⚡ Quick Setup (recommended)",
150
+ action: async () => { await claudeQuickSetup(port); return true; }
151
+ },
152
+ {
153
+ label: (d) => `Sonnet → ${d.sonnet}`,
154
+ action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
155
+ },
156
+ {
157
+ label: (d) => `Opus → ${d.opus}`,
158
+ action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
159
+ },
160
+ {
161
+ label: (d) => `Haiku → ${d.haiku}`,
162
+ action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
163
+ },
164
+ {
165
+ label: "Reset to Default",
166
+ action: async () => { await claudeReset(); return true; }
167
+ }
168
+ ]
169
+ });
170
+ }
171
+
172
+ // ─── Codex CLI ────────────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Build header showing current Codex config status
176
+ * @returns {Promise<string>}
177
+ */
178
+ async function buildCodexHeader() {
179
+ const result = await api.getCliToolSettings("codex");
180
+ if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
181
+
182
+ const { installed, has9Router, config } = result.data;
183
+ if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
184
+
185
+ if (!has9Router) {
186
+ return [
187
+ `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
188
+ `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
189
+ ].join("\n");
190
+ }
191
+
192
+ // Parse base_url and model from raw TOML string
193
+ const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
194
+ const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
195
+ const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
196
+ const model = modelMatch ? modelMatch[1] : "";
197
+
198
+ const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
199
+ if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
200
+ if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
201
+ return lines.join("\n");
202
+ }
203
+
204
+ /**
205
+ * Quick setup for Codex CLI
206
+ * @param {number} port
207
+ */
208
+ async function codexQuickSetup(port) {
209
+ const { endpoint } = await getEndpoint(port);
210
+ const apiKey = await getFirstApiKey();
211
+
212
+ if (!apiKey) {
213
+ showStatus("No API keys found. Create one in API Keys menu first.", "error");
214
+ await pause();
215
+ return;
216
+ }
217
+
218
+ // Get model selection
219
+ const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
220
+ if (!model) return;
221
+
222
+ const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
223
+ showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
224
+ await pause();
225
+ }
226
+
227
+ /**
228
+ * Reset Codex CLI settings
229
+ */
230
+ async function codexReset() {
231
+ const result = await api.resetCliToolSettings("codex");
232
+ showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
233
+ await pause();
234
+ }
235
+
236
+ /**
237
+ * Codex CLI submenu
238
+ * @param {number} port
239
+ * @param {Array<string>} breadcrumb
240
+ */
241
+ async function showCodexMenu(port, breadcrumb = []) {
242
+ await showMenuWithBack({
243
+ title: "🤖 Codex CLI Settings",
244
+ breadcrumb,
245
+ headerContent: buildCodexHeader,
246
+ refresh: async () => ({}),
247
+ items: [
248
+ {
249
+ label: "⚡ Quick Setup",
250
+ action: async () => { await codexQuickSetup(port); return true; }
251
+ },
252
+ {
253
+ label: "Reset to Default",
254
+ action: async () => { await codexReset(); return true; }
255
+ }
256
+ ]
257
+ });
258
+ }
259
+
260
+ // ─── Factory Droid ────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Build header showing current Droid config status
264
+ * @returns {Promise<string>}
265
+ */
266
+ async function buildDroidHeader() {
267
+ const result = await api.getCliToolSettings("droid");
268
+ if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
269
+
270
+ const { installed, has9Router, settings } = result.data;
271
+ if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
272
+
273
+ if (!has9Router) {
274
+ return [
275
+ `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
276
+ `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
277
+ ].join("\n");
278
+ }
279
+
280
+ // Extract 9Router custom model config
281
+ const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
282
+ const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
283
+ if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
284
+ if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
285
+ return lines.join("\n");
286
+ }
287
+
288
+ /**
289
+ * Quick setup for Factory Droid
290
+ * @param {number} port
291
+ */
292
+ async function droidQuickSetup(port) {
293
+ const { endpoint } = await getEndpoint(port);
294
+ const apiKey = await getFirstApiKey();
295
+
296
+ if (!apiKey) {
297
+ showStatus("No API keys found. Create one in API Keys menu first.", "error");
298
+ await pause();
299
+ return;
300
+ }
301
+
302
+ const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
303
+ if (!model) return;
304
+
305
+ const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
306
+ showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
307
+ await pause();
308
+ }
309
+
310
+ /**
311
+ * Reset Factory Droid settings
312
+ */
313
+ async function droidReset() {
314
+ const result = await api.resetCliToolSettings("droid");
315
+ showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
316
+ await pause();
317
+ }
318
+
319
+ /**
320
+ * Factory Droid submenu
321
+ * @param {number} port
322
+ * @param {Array<string>} breadcrumb
323
+ */
324
+ async function showDroidMenu(port, breadcrumb = []) {
325
+ await showMenuWithBack({
326
+ title: "🤖 Factory Droid Settings",
327
+ breadcrumb,
328
+ headerContent: buildDroidHeader,
329
+ refresh: async () => ({}),
330
+ items: [
331
+ {
332
+ label: "⚡ Quick Setup",
333
+ action: async () => { await droidQuickSetup(port); return true; }
334
+ },
335
+ {
336
+ label: "Reset to Default",
337
+ action: async () => { await droidReset(); return true; }
338
+ }
339
+ ]
340
+ });
341
+ }
342
+
343
+ // ─── Open Claw ────────────────────────────────────────────────────────────────
344
+
345
+ /**
346
+ * Build header showing current OpenClaw config status
347
+ * @returns {Promise<string>}
348
+ */
349
+ async function buildOpenClawHeader() {
350
+ const result = await api.getCliToolSettings("openclaw");
351
+ if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
352
+
353
+ const { installed, has9Router, settings } = result.data;
354
+ if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
355
+
356
+ if (!has9Router) {
357
+ return [
358
+ `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
359
+ `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
360
+ ].join("\n");
361
+ }
362
+
363
+ // Extract 9Router provider config
364
+ const provider = settings?.models?.providers?.["9router"];
365
+ const primary = settings?.agents?.defaults?.model?.primary || "";
366
+ const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
367
+ const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
368
+ if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
369
+ if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
370
+ return lines.join("\n");
371
+ }
372
+
373
+ /**
374
+ * Quick setup for Open Claw
375
+ * @param {number} port
376
+ */
377
+ async function openClawQuickSetup(port) {
378
+ const { endpoint } = await getEndpoint(port);
379
+ const apiKey = await getFirstApiKey();
380
+
381
+ if (!apiKey) {
382
+ showStatus("No API keys found. Create one in API Keys menu first.", "error");
383
+ await pause();
384
+ return;
385
+ }
386
+
387
+ const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
388
+ if (!model) return;
389
+
390
+ const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
391
+ showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
392
+ await pause();
393
+ }
394
+
395
+ /**
396
+ * Reset Open Claw settings
397
+ */
398
+ async function openClawReset() {
399
+ const result = await api.resetCliToolSettings("openclaw");
400
+ showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
401
+ await pause();
402
+ }
403
+
404
+ /**
405
+ * Open Claw submenu
406
+ * @param {number} port
407
+ * @param {Array<string>} breadcrumb
408
+ */
409
+ async function showOpenClawMenu(port, breadcrumb = []) {
410
+ await showMenuWithBack({
411
+ title: "🦞 Open Claw Settings",
412
+ breadcrumb,
413
+ headerContent: buildOpenClawHeader,
414
+ refresh: async () => ({}),
415
+ items: [
416
+ {
417
+ label: "⚡ Quick Setup",
418
+ action: async () => { await openClawQuickSetup(port); return true; }
419
+ },
420
+ {
421
+ label: "Reset to Default",
422
+ action: async () => { await openClawReset(); return true; }
423
+ }
424
+ ]
425
+ });
426
+ }
427
+
428
+ // ─── OpenCode CLI ─────────────────────────────────────────────────────────────
429
+
430
+ async function buildOpenCodeHeader() {
431
+ const result = await api.getCliToolSettings("opencode");
432
+ if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
433
+
434
+ const { installed, has9Router, opencode } = result.data;
435
+ if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
436
+
437
+ if (!has9Router) {
438
+ return [
439
+ `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
440
+ `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
441
+ ].join("\n");
442
+ }
443
+
444
+ const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
445
+ if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
446
+ if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
447
+ if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
448
+ lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
449
+ }
450
+ return lines.join("\n");
451
+ }
452
+
453
+ async function openCodeQuickSetup(port) {
454
+ const { endpoint } = await getEndpoint(port);
455
+ const apiKey = await getFirstApiKey();
456
+
457
+ if (!apiKey) {
458
+ showStatus("No API keys found. Create one in API Keys menu first.", "error");
459
+ await pause();
460
+ return;
461
+ }
462
+
463
+ // Pick first model (also becomes active model by default)
464
+ const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
465
+ if (!firstModel) return;
466
+
467
+ const models = [firstModel];
468
+
469
+ // Optionally add more models
470
+ while (true) {
471
+ const more = await confirm(`Add another model? (current: ${models.length})`);
472
+ if (!more) break;
473
+ const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
474
+ if (!next) break;
475
+ if (!models.includes(next)) models.push(next);
476
+ }
477
+
478
+ // Optional subagent model
479
+ let subagentModel = firstModel;
480
+ const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
481
+ if (wantSubagent) {
482
+ const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
483
+ if (picked) subagentModel = picked;
484
+ }
485
+
486
+ const result = await api.applyCliToolSettings("opencode", {
487
+ baseUrl: endpoint,
488
+ apiKey,
489
+ models,
490
+ activeModel: firstModel,
491
+ subagentModel,
492
+ });
493
+ showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
494
+ await pause();
495
+ }
496
+
497
+ async function openCodeReset() {
498
+ const result = await api.resetCliToolSettings("opencode");
499
+ showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
500
+ await pause();
501
+ }
502
+
503
+ async function showOpenCodeMenu(port, breadcrumb = []) {
504
+ await showMenuWithBack({
505
+ title: "💻 OpenCode CLI Settings",
506
+ breadcrumb,
507
+ headerContent: buildOpenCodeHeader,
508
+ refresh: async () => ({}),
509
+ items: [
510
+ { label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
511
+ { label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
512
+ ]
513
+ });
514
+ }
515
+
516
+ // ─── Hermes Agent ─────────────────────────────────────────────────────────────
517
+
518
+ async function buildHermesHeader() {
519
+ const result = await api.getCliToolSettings("hermes");
520
+ if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
521
+
522
+ const { installed, has9Router, settings } = result.data;
523
+ if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
524
+
525
+ if (!has9Router) {
526
+ return [
527
+ `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
528
+ `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
529
+ ].join("\n");
530
+ }
531
+
532
+ const model = settings?.model || {};
533
+ const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
534
+ if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
535
+ if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
536
+ return lines.join("\n");
537
+ }
538
+
539
+ async function hermesQuickSetup(port) {
540
+ const { endpoint } = await getEndpoint(port);
541
+ const apiKey = await getFirstApiKey();
542
+
543
+ if (!apiKey) {
544
+ showStatus("No API keys found. Create one in API Keys menu first.", "error");
545
+ await pause();
546
+ return;
547
+ }
548
+
549
+ const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
550
+ if (!model) return;
551
+
552
+ const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
553
+ showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
554
+ await pause();
555
+ }
556
+
557
+ async function hermesReset() {
558
+ const result = await api.resetCliToolSettings("hermes");
559
+ showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
560
+ await pause();
561
+ }
562
+
563
+ async function showHermesMenu(port, breadcrumb = []) {
564
+ await showMenuWithBack({
565
+ title: "⚡ Hermes Agent Settings",
566
+ breadcrumb,
567
+ headerContent: buildHermesHeader,
568
+ refresh: async () => ({}),
569
+ items: [
570
+ { label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
571
+ { label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
572
+ ]
573
+ });
574
+ }
575
+
576
+ // ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
577
+
578
+ /**
579
+ * Main CLI Tools menu
580
+ * @param {number} port
581
+ * @param {Array<string>} breadcrumb
582
+ */
583
+ async function showCliToolsMenu(port, breadcrumb = []) {
584
+ const { endpoint } = await getEndpoint(port);
585
+ await showMenuWithBack({
586
+ title: "🔧 CLI Tools",
587
+ breadcrumb,
588
+ headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
589
+ items: [
590
+ {
591
+ label: "Claude Code",
592
+ action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
593
+ },
594
+ {
595
+ label: "Codex CLI",
596
+ action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
597
+ },
598
+ {
599
+ label: "Factory Droid",
600
+ action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
601
+ },
602
+ {
603
+ label: "Open Claw",
604
+ action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
605
+ },
606
+ {
607
+ label: "OpenCode",
608
+ action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
609
+ },
610
+ {
611
+ label: "Hermes",
612
+ action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
613
+ }
614
+ ]
615
+ });
616
+ }
617
+
618
+ module.exports = { showCliToolsMenu };
cli/src/cli/menus/combos.js ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+ const { prompt, confirm, pause } = require("../utils/input");
3
+ const { clearScreen, showStatus, showHeader } = require("../utils/display");
4
+ const { formatDate } = require("../utils/format");
5
+ const { selectModelFromList } = require("../utils/modelSelector");
6
+ const { showMenuWithBack } = require("../utils/menuHelper");
7
+
8
+ /**
9
+ * Format model to string (handle both string and object)
10
+ */
11
+ function formatModel(model) {
12
+ if (typeof model === "string") return model;
13
+ if (model && typeof model === "object") {
14
+ return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model);
15
+ }
16
+ return String(model);
17
+ }
18
+
19
+ /**
20
+ * Show actions for a specific combo
21
+ * @param {Object} combo - Combo object
22
+ * @param {Array<string>} breadcrumb - Breadcrumb path
23
+ */
24
+ async function showComboActions(combo, breadcrumb = []) {
25
+ const modelsChain = Array.isArray(combo.models)
26
+ ? combo.models.map(formatModel).join(" → ")
27
+ : "";
28
+
29
+ await showMenuWithBack({
30
+ title: `🔀 ${combo.name}`,
31
+ breadcrumb: [...breadcrumb, combo.name],
32
+ headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`,
33
+ items: [
34
+ {
35
+ label: "Edit Combo",
36
+ action: async () => {
37
+ await handleEditSingleCombo(combo);
38
+ return true;
39
+ }
40
+ },
41
+ {
42
+ label: "Delete Combo",
43
+ action: async () => {
44
+ await handleDeleteSingleCombo(combo);
45
+ return false; // Exit after delete
46
+ }
47
+ }
48
+ ]
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Handle editing a single combo
54
+ * @param {Object} combo - Combo to edit
55
+ */
56
+ async function handleEditSingleCombo(combo) {
57
+ clearScreen();
58
+ console.log(`\n✏️ Edit Combo: ${combo.name}\n`);
59
+
60
+ const newName = await prompt(`New name (Enter to keep "${combo.name}"): `);
61
+ const name = newName || combo.name;
62
+
63
+ console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""));
64
+ console.log("\nSelect models for this combo (add one by one):");
65
+
66
+ const models = [];
67
+ let addMore = true;
68
+
69
+ while (addMore) {
70
+ const currentChain = models.length > 0 ? models.join(" → ") : "None";
71
+ const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`);
72
+
73
+ if (model) {
74
+ models.push(model);
75
+ console.log(`\n✓ Added: ${model}`);
76
+ console.log(`Current chain: ${models.join(" → ")}\n`);
77
+
78
+ const continueAdding = await confirm("Add another model?");
79
+ addMore = continueAdding;
80
+ } else {
81
+ addMore = false;
82
+ }
83
+ }
84
+
85
+ // Use new models if any were added, otherwise keep current
86
+ const finalModels = models.length > 0 ? models : combo.models;
87
+
88
+ const result = await api.updateCombo(combo.id, { name, models: finalModels });
89
+
90
+ if (result.success) {
91
+ showStatus("Combo updated!", "success");
92
+ } else {
93
+ showStatus(`Update failed: ${result.error}`, "error");
94
+ }
95
+ await pause();
96
+ }
97
+
98
+ /**
99
+ * Handle deleting a single combo
100
+ * @param {Object} combo - Combo to delete
101
+ */
102
+ async function handleDeleteSingleCombo(combo) {
103
+ const confirmed = await confirm(`Delete combo "${combo.name}"?`);
104
+ if (confirmed) {
105
+ const result = await api.deleteCombo(combo.id);
106
+ if (result.success) {
107
+ showStatus("Combo deleted!", "success");
108
+ } else {
109
+ showStatus(`Delete failed: ${result.error}`, "error");
110
+ }
111
+ await pause();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Main combos menu - list all combos and actions
117
+ * @param {Array<string>} breadcrumb - Breadcrumb path
118
+ */
119
+ async function showCombosMenu(breadcrumb = []) {
120
+ const { showListMenu } = require("../utils/menuHelper");
121
+
122
+ await showListMenu({
123
+ title: "🔀 Combos Management",
124
+ breadcrumb,
125
+ fetchItems: async () => {
126
+ const result = await api.getCombos();
127
+ if (!result.success) {
128
+ clearScreen();
129
+ showStatus(`Failed to load combos: ${result.error}`, "error");
130
+ await pause();
131
+ return null;
132
+ }
133
+ return { items: result.data.combos || [] };
134
+ },
135
+ formatItem: (combo) => {
136
+ const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
137
+ const maxLen = 35;
138
+ const displayModels = modelsChain.length > maxLen
139
+ ? modelsChain.substring(0, maxLen - 3) + "..."
140
+ : modelsChain;
141
+ return `${combo.name}: ${displayModels}`;
142
+ },
143
+ onSelect: async (combo) => {
144
+ await showComboActions(combo, breadcrumb);
145
+ },
146
+ createAction: {
147
+ label: "Create New Combo",
148
+ action: async () => {
149
+ await handleCreateCombo();
150
+ }
151
+ }
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Show combo detail with stats
157
+ */
158
+ async function showComboDetail(comboId) {
159
+ clearScreen();
160
+
161
+ const result = await api.getComboById(comboId);
162
+
163
+ if (!result.success) {
164
+ showStatus(`Failed to load combo: ${result.error}`, "error");
165
+ await pause();
166
+ return;
167
+ }
168
+
169
+ const combo = result.data;
170
+
171
+ console.log("┌─────────────────────────────────────────────────────────┐");
172
+ console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)} │`);
173
+ console.log("├─────────────────────────────────────────────────────────┤");
174
+ console.log("│ │");
175
+ console.log(`│ ID: ${combo.id.padEnd(51)} │`);
176
+ console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)} │`);
177
+ console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)} │`);
178
+ console.log("│ │");
179
+ console.log("│ Model Chain: │");
180
+
181
+ // Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"]
182
+ const models = Array.isArray(combo.models) ? combo.models : [];
183
+ models.forEach((modelStr, index) => {
184
+ const arrow = index < models.length - 1 ? " →" : " ";
185
+ const displayText = `${index + 1}. ${modelStr}${arrow}`;
186
+ const padding = Math.max(0, 54 - displayText.length);
187
+ console.log(`│ ${displayText}${" ".repeat(padding)} │`);
188
+ });
189
+
190
+ console.log("│ │");
191
+ console.log("└─────────────────────────────────────────────────────────┘");
192
+
193
+ await pause();
194
+ }
195
+
196
+ /**
197
+ * Format combo for menu display
198
+ */
199
+ function formatComboLabel(combo) {
200
+ const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
201
+ const maxLen = 40;
202
+ const displayModels = modelsChain.length > maxLen
203
+ ? modelsChain.substring(0, maxLen - 3) + "..."
204
+ : modelsChain;
205
+ return `${combo.name}: ${displayModels}`;
206
+ }
207
+
208
+ /**
209
+ * Create new combo
210
+ */
211
+ async function handleCreateCombo() {
212
+ clearScreen();
213
+
214
+ showStatus("Create New Combo", "info");
215
+ console.log();
216
+
217
+ // Get combo name
218
+ const name = await prompt("Combo name: ");
219
+ if (!name) {
220
+ showStatus("Combo name is required", "error");
221
+ await pause();
222
+ return;
223
+ }
224
+
225
+ // Fetch available models
226
+ showStatus("Loading available models...", "info");
227
+ const modelsResult = await api.getModels();
228
+
229
+ if (!modelsResult.success) {
230
+ showStatus(`Failed to load models: ${modelsResult.error}`, "error");
231
+ await pause();
232
+ return;
233
+ }
234
+
235
+ const availableModels = modelsResult.data.models || [];
236
+
237
+ if (availableModels.length === 0) {
238
+ showStatus("No models available. Please add providers first.", "warning");
239
+ await pause();
240
+ return;
241
+ }
242
+
243
+ // Select models for chain
244
+ const selectedModels = [];
245
+
246
+ console.log();
247
+ showStatus("Select models for the chain (minimum 2)", "info");
248
+
249
+ while (true) {
250
+ clearScreen();
251
+ console.log(`Creating combo: ${name}`);
252
+ console.log(`Selected models (${selectedModels.length}):`);
253
+
254
+ if (selectedModels.length > 0) {
255
+ selectedModels.forEach((m, i) => {
256
+ console.log(` ${i + 1}. ${m.provider}/${m.model}`);
257
+ });
258
+ } else {
259
+ console.log(" (none)");
260
+ }
261
+
262
+ console.log();
263
+ console.log("Available models:");
264
+ availableModels.forEach((m, i) => {
265
+ console.log(` ${i + 1}. ${m.provider}/${m.model}`);
266
+ });
267
+
268
+ console.log();
269
+ console.log("Actions:");
270
+ console.log(" - Enter number to add model");
271
+ console.log(" - Type 'done' to finish (min 2 models)");
272
+ console.log(" - Type 'cancel' to abort");
273
+
274
+ const input = await prompt("\nAction: ");
275
+
276
+ if (input.toLowerCase() === "cancel") {
277
+ showStatus("Cancelled", "warning");
278
+ await pause();
279
+ return;
280
+ }
281
+
282
+ if (input.toLowerCase() === "done") {
283
+ if (selectedModels.length < 2) {
284
+ showStatus("Please select at least 2 models", "error");
285
+ await pause();
286
+ continue;
287
+ }
288
+ break;
289
+ }
290
+
291
+ const num = parseInt(input, 10);
292
+ if (isNaN(num) || num < 1 || num > availableModels.length) {
293
+ showStatus("Invalid model number", "error");
294
+ await pause();
295
+ continue;
296
+ }
297
+
298
+ selectedModels.push(availableModels[num - 1]);
299
+ }
300
+
301
+ // Create combo
302
+ showStatus("Creating combo...", "info");
303
+
304
+ const createResult = await api.createCombo({
305
+ name,
306
+ models: selectedModels
307
+ });
308
+
309
+ if (!createResult.success) {
310
+ showStatus(`Failed to create combo: ${createResult.error}`, "error");
311
+ await pause();
312
+ return;
313
+ }
314
+
315
+ showStatus(`Combo "${name}" created successfully!`, "success");
316
+ await pause();
317
+ }
318
+
319
+ /**
320
+ * Edit combo - select which combo to edit
321
+ */
322
+ async function handleEditCombo(combos) {
323
+ if (combos.length === 0) {
324
+ showStatus("No combos available", "warning");
325
+ await pause();
326
+ return;
327
+ }
328
+
329
+ let selectedCombo = null;
330
+
331
+ await showMenuWithBack({
332
+ title: "✏️ Select Combo to Edit",
333
+ items: combos.map(combo => ({
334
+ label: formatComboLabel(combo),
335
+ action: async () => {
336
+ selectedCombo = combo;
337
+ return false;
338
+ }
339
+ }))
340
+ });
341
+
342
+ if (!selectedCombo) return;
343
+ await editSingleCombo(selectedCombo);
344
+ }
345
+
346
+ /**
347
+ * Edit a single combo
348
+ */
349
+ async function editSingleCombo(combo) {
350
+ clearScreen();
351
+ showStatus(`Editing combo: ${combo.name}`, "info");
352
+ console.log();
353
+
354
+ const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `);
355
+ const editModels = await confirm("Edit model chain?");
356
+
357
+ let newModels = combo.models;
358
+
359
+ if (editModels) {
360
+ newModels = [];
361
+
362
+ while (true) {
363
+ clearScreen();
364
+ console.log(`Editing combo: ${combo.name}`);
365
+ console.log(`Selected models (${newModels.length}):`);
366
+
367
+ if (newModels.length > 0) {
368
+ newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
369
+ } else {
370
+ console.log(" (none)");
371
+ }
372
+
373
+ console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n");
374
+
375
+ const model = await selectModelFromList("Add Model", "");
376
+
377
+ if (model === null) {
378
+ showStatus("Cancelled", "warning");
379
+ await pause();
380
+ return;
381
+ }
382
+
383
+ if (model === "done") {
384
+ if (newModels.length < 2) {
385
+ showStatus("Please select at least 2 models", "error");
386
+ await pause();
387
+ continue;
388
+ }
389
+ break;
390
+ }
391
+
392
+ newModels.push(model);
393
+ showStatus(`Added: ${model}`, "success");
394
+ await pause();
395
+ }
396
+ }
397
+
398
+ const updateData = {};
399
+ if (newName) updateData.name = newName;
400
+ if (editModels) updateData.models = newModels;
401
+
402
+ if (Object.keys(updateData).length === 0) {
403
+ showStatus("No changes made", "warning");
404
+ await pause();
405
+ return;
406
+ }
407
+
408
+ showStatus("Updating combo...", "info");
409
+
410
+ const updateResult = await api.updateCombo(combo.id, updateData);
411
+
412
+ if (!updateResult.success) {
413
+ showStatus(`Failed to update combo: ${updateResult.error}`, "error");
414
+ await pause();
415
+ return;
416
+ }
417
+
418
+ showStatus("Combo updated successfully!", "success");
419
+ await pause();
420
+ }
421
+
422
+ /**
423
+ * Delete combo - select which combo to delete
424
+ */
425
+ async function handleDeleteCombo(combos) {
426
+ if (combos.length === 0) {
427
+ showStatus("No combos available", "warning");
428
+ await pause();
429
+ return;
430
+ }
431
+
432
+ let selectedCombo = null;
433
+
434
+ await showMenuWithBack({
435
+ title: "🗑️ Select Combo to Delete",
436
+ items: combos.map(combo => ({
437
+ label: formatComboLabel(combo),
438
+ action: async () => {
439
+ selectedCombo = combo;
440
+ return false;
441
+ }
442
+ }))
443
+ });
444
+
445
+ if (!selectedCombo) return;
446
+
447
+ clearScreen();
448
+ showStatus(`Combo: ${selectedCombo.name}`, "warning");
449
+ const modelsDisplay = Array.isArray(selectedCombo.models)
450
+ ? selectedCombo.models.map(formatModel).join(" → ")
451
+ : "";
452
+ console.log(`Models: ${modelsDisplay}`);
453
+ console.log();
454
+
455
+ const confirmed = await confirm("Are you sure you want to delete this combo?");
456
+
457
+ if (!confirmed) {
458
+ showStatus("Cancelled", "info");
459
+ await pause();
460
+ return;
461
+ }
462
+
463
+ showStatus("Deleting combo...", "info");
464
+
465
+ const deleteResult = await api.deleteCombo(selectedCombo.id);
466
+
467
+ if (!deleteResult.success) {
468
+ showStatus(`Failed to delete combo: ${deleteResult.error}`, "error");
469
+ await pause();
470
+ return;
471
+ }
472
+
473
+ showStatus("Combo deleted successfully!", "success");
474
+ await pause();
475
+ }
476
+
477
+ module.exports = { showCombosMenu };
cli/src/cli/menus/providers.js ADDED
@@ -0,0 +1,846 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+ const { prompt, confirm, pause } = require("../utils/input");
3
+ const { clearScreen, showStatus, showHeader } = require("../utils/display");
4
+ const { formatDate, getRelativeTime } = require("../utils/format");
5
+ const { showMenuWithBack } = require("../utils/menuHelper");
6
+ const { copyToClipboard } = require("../utils/clipboard");
7
+
8
+ // ANSI colors for styling
9
+ const COLORS = {
10
+ reset: "\x1b[0m",
11
+ bold: "\x1b[1m",
12
+ cyan: "\x1b[36m",
13
+ dim: "\x1b[2m"
14
+ };
15
+
16
+ // Provider models - static config (synced from open-sse/config/providerModels.js)
17
+ const PROVIDER_MODELS = {
18
+ cc: [
19
+ { id: "claude-opus-4-5-20251101" },
20
+ { id: "claude-sonnet-4-5-20250929" },
21
+ { id: "claude-haiku-4-5-20251001" },
22
+ ],
23
+ cx: [
24
+ { id: "gpt-5.2-codex" },
25
+ { id: "gpt-5.2" },
26
+ { id: "gpt-5.1-codex-max" },
27
+ { id: "gpt-5.1-codex" },
28
+ { id: "gpt-5.1-codex-mini" },
29
+ { id: "gpt-5.1" },
30
+ { id: "gpt-5-codex" },
31
+ { id: "gpt-5-codex-mini" },
32
+ ],
33
+ gc: [
34
+ { id: "gemini-3-flash-preview" },
35
+ { id: "gemini-3-pro-preview" },
36
+ { id: "gemini-2.5-pro" },
37
+ { id: "gemini-2.5-flash" },
38
+ { id: "gemini-2.5-flash-lite" },
39
+ ],
40
+ qw: [
41
+ { id: "qwen3-coder-plus" },
42
+ { id: "qwen3-coder-flash" },
43
+ { id: "vision-model" },
44
+ ],
45
+ if: [
46
+ { id: "qwen3-coder-plus" },
47
+ { id: "kimi-k2" },
48
+ { id: "kimi-k2-thinking" },
49
+ { id: "deepseek-r1" },
50
+ { id: "deepseek-v3.2-chat" },
51
+ { id: "deepseek-v3.2-reasoner" },
52
+ { id: "minimax-m2" },
53
+ { id: "glm-4.7" },
54
+ ],
55
+ ag: [
56
+ { id: "gemini-3-flash-agent" },
57
+ { id: "gemini-3.5-flash-low" },
58
+ { id: "gemini-3.5-flash-extra-low" },
59
+ { id: "gemini-pro-agent" },
60
+ { id: "gemini-3.1-pro-low" },
61
+ { id: "claude-sonnet-4-6" },
62
+ { id: "claude-opus-4-6-thinking" },
63
+ { id: "gpt-oss-120b-medium" },
64
+ { id: "gemini-3-flash" },
65
+ ],
66
+ gh: [
67
+ { id: "gpt-5" },
68
+ { id: "gpt-5-mini" },
69
+ { id: "gpt-5.1-codex" },
70
+ { id: "gpt-5.1-codex-max" },
71
+ { id: "gpt-4.1" },
72
+ { id: "claude-4.5-sonnet" },
73
+ { id: "claude-4.5-opus" },
74
+ { id: "claude-4.5-haiku" },
75
+ { id: "gemini-3-pro" },
76
+ { id: "gemini-3-flash" },
77
+ { id: "gemini-2.5-pro" },
78
+ { id: "grok-code-fast-1" },
79
+ ],
80
+ kr: [
81
+ { id: "claude-sonnet-4.5" },
82
+ { id: "claude-haiku-4.5" },
83
+ ],
84
+ openai: [
85
+ { id: "gpt-4o" },
86
+ { id: "gpt-4o-mini" },
87
+ { id: "gpt-4-turbo" },
88
+ { id: "o1" },
89
+ { id: "o1-mini" },
90
+ ],
91
+ anthropic: [
92
+ { id: "claude-sonnet-4-20250514" },
93
+ { id: "claude-opus-4-20250514" },
94
+ { id: "claude-3-5-sonnet-20241022" },
95
+ ],
96
+ gemini: [
97
+ { id: "gemini-3-pro-preview" },
98
+ { id: "gemini-2.5-pro" },
99
+ { id: "gemini-2.5-flash" },
100
+ { id: "gemini-2.5-flash-lite" },
101
+ ],
102
+ openrouter: [
103
+ { id: "auto" },
104
+ ],
105
+ glm: [
106
+ { id: "glm-4.7" },
107
+ { id: "glm-4.6v" },
108
+ ],
109
+ kimi: [
110
+ { id: "kimi-latest" },
111
+ ],
112
+ minimax: [
113
+ { id: "MiniMax-M2.1" },
114
+ ],
115
+ };
116
+
117
+ // Provider definitions
118
+ const OAUTH_PROVIDERS = {
119
+ claude: { id: "claude", alias: "cc", name: "Claude Code" },
120
+ codex: { id: "codex", alias: "cx", name: "OpenAI Codex" },
121
+ "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" },
122
+ github: { id: "github", alias: "gh", name: "GitHub Copilot" },
123
+ antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" },
124
+ iflow: { id: "iflow", alias: "if", name: "iFlow AI" },
125
+ qwen: { id: "qwen", alias: "qw", name: "Qwen Code" },
126
+ kiro: { id: "kiro", alias: "kr", name: "Kiro AI" },
127
+ };
128
+
129
+ const APIKEY_PROVIDERS = {
130
+ openrouter: { id: "openrouter", name: "OpenRouter" },
131
+ glm: { id: "glm", name: "GLM Coding" },
132
+ minimax: { id: "minimax", name: "Minimax Coding" },
133
+ kimi: { id: "kimi", name: "Kimi Coding" },
134
+ openai: { id: "openai", name: "OpenAI" },
135
+ anthropic: { id: "anthropic", name: "Anthropic" },
136
+ gemini: { id: "gemini", name: "Gemini" },
137
+ };
138
+
139
+ const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
140
+
141
+ /**
142
+ * Get auth type for provider
143
+ * @param {string} providerId - Provider ID
144
+ * @returns {string} "oauth" or "apikey"
145
+ */
146
+ function getAuthType(providerId) {
147
+ return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey";
148
+ }
149
+
150
+ /**
151
+ * Count connections by provider
152
+ * @param {Array} connections - Array of connection objects
153
+ * @returns {Object} Map of providerId -> count
154
+ */
155
+ function countConnectionsByProvider(connections) {
156
+ const counts = {};
157
+ connections.forEach(conn => {
158
+ const providerId = conn.provider || conn.providerId;
159
+ counts[providerId] = (counts[providerId] || 0) + 1;
160
+ });
161
+ return counts;
162
+ }
163
+
164
+ /**
165
+ * Show main providers menu
166
+ * @param {Array<string>} breadcrumb - Breadcrumb path
167
+ */
168
+ async function showProvidersMenu(breadcrumb = []) {
169
+ // Build provider items list
170
+ const providerItems = [];
171
+
172
+ Object.values(OAUTH_PROVIDERS).forEach(provider => {
173
+ providerItems.push({
174
+ provider,
175
+ authType: "oauth",
176
+ label: (data) => {
177
+ const count = data.counts[provider.id] || 0;
178
+ return `${provider.name} (OAuth) - ${count} Connected`;
179
+ },
180
+ action: async (data) => {
181
+ await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]);
182
+ return true;
183
+ }
184
+ });
185
+ });
186
+
187
+ Object.values(APIKEY_PROVIDERS).forEach(provider => {
188
+ providerItems.push({
189
+ provider,
190
+ authType: "apikey",
191
+ label: (data) => {
192
+ const count = data.counts[provider.id] || 0;
193
+ return `${provider.name} (API) - ${count} Connected`;
194
+ },
195
+ action: async (data) => {
196
+ await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]);
197
+ return true;
198
+ }
199
+ });
200
+ });
201
+
202
+ // Custom provider nodes section
203
+ providerItems.push({
204
+ label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`,
205
+ action: async () => true, // separator, no-op
206
+ isSeparator: true,
207
+ });
208
+ providerItems.push({
209
+ label: (data) => {
210
+ const count = data.nodeCount || 0;
211
+ return `Custom Providers - ${count} Configured`;
212
+ },
213
+ action: async () => {
214
+ await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]);
215
+ return true;
216
+ }
217
+ });
218
+
219
+ await showMenuWithBack({
220
+ title: "🔌 Providers Management",
221
+ breadcrumb,
222
+ refresh: async () => {
223
+ const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]);
224
+ if (!provRes.success) {
225
+ showStatus(`Failed to fetch providers: ${provRes.error}`, "error");
226
+ await pause();
227
+ return null;
228
+ }
229
+ const connections = provRes.data.connections || [];
230
+ const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : [];
231
+ return {
232
+ connections,
233
+ counts: countConnectionsByProvider(connections),
234
+ nodeCount: nodes.length,
235
+ };
236
+ },
237
+ items: providerItems
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Build provider header with alias and models
243
+ * @param {string} providerId - Provider ID
244
+ * @returns {string}
245
+ */
246
+ function buildProviderHeader(providerId) {
247
+ const provider = ALL_PROVIDERS[providerId];
248
+ const alias = provider.alias || providerId;
249
+
250
+ const lines = [];
251
+ lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`);
252
+
253
+ // Get models from static config
254
+ const models = PROVIDER_MODELS[alias] || [];
255
+ if (models.length > 0) {
256
+ const modelList = models
257
+ .slice(0, 5)
258
+ .map(m => `${alias}/${m.id}`)
259
+ .join(", ");
260
+ const more = models.length > 5 ? ` (+${models.length - 5} more)` : "";
261
+ lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`);
262
+ } else {
263
+ lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`);
264
+ }
265
+
266
+ return lines.join("\n");
267
+ }
268
+
269
+ /**
270
+ * Show provider detail with connections and actions
271
+ * @param {string} providerId - Provider ID
272
+ * @param {string} authType - "oauth" or "apikey"
273
+ * @param {Array} allConnections - All connections
274
+ * @param {Array<string>} breadcrumb - Breadcrumb path
275
+ */
276
+ async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) {
277
+ const provider = ALL_PROVIDERS[providerId];
278
+ const { showListMenu } = require("../utils/menuHelper");
279
+
280
+ await showListMenu({
281
+ title: `🔌 ${provider.name} (${authType.toUpperCase()})`,
282
+ breadcrumb,
283
+ backLabel: "← Back to Providers",
284
+ headerContent: buildProviderHeader(providerId),
285
+ fetchItems: async () => {
286
+ const response = await api.getProviders();
287
+ if (response.success) {
288
+ allConnections.length = 0;
289
+ allConnections.push(...(response.data.connections || []));
290
+ }
291
+ const providerConns = allConnections.filter(conn =>
292
+ (conn.provider || conn.providerId) === providerId
293
+ );
294
+ return { items: providerConns };
295
+ },
296
+ formatItem: (conn) => {
297
+ const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
298
+ const name = conn.name || conn.email || conn.displayName || "Unnamed";
299
+ return `${name} (${status})`;
300
+ },
301
+ onSelect: async (conn) => {
302
+ await showConnectionActions(conn, providerId, breadcrumb);
303
+ },
304
+ createAction: {
305
+ label: "Add New Connection",
306
+ action: async () => {
307
+ await handleAddConnection(providerId, authType);
308
+ }
309
+ }
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Show actions for a specific connection
315
+ * @param {Object} connection - Connection object
316
+ * @param {string} providerId - Provider ID
317
+ * @param {Array<string>} breadcrumb - Breadcrumb path
318
+ */
319
+ async function showConnectionActions(connection, providerId, breadcrumb = []) {
320
+ const name = connection.name || connection.email || connection.displayName || "Unnamed";
321
+ const status = connection.testStatus === "active" ? "✓ Active" :
322
+ connection.testStatus === "error" ? "✗ Error" : "? Unknown";
323
+
324
+ await showMenuWithBack({
325
+ title: `🔌 ${name}`,
326
+ breadcrumb: [...breadcrumb, name],
327
+ headerContent: `Connection: ${name}\nStatus: ${status}`,
328
+ items: [
329
+ {
330
+ label: "Rename Connection",
331
+ action: async () => {
332
+ const newName = await prompt(`New name (current: ${name}): `);
333
+ if (newName && newName.trim()) {
334
+ showStatus("Renaming connection...", "info");
335
+ const result = await api.updateConnection(connection.id, { name: newName.trim() });
336
+ if (result.success) {
337
+ showStatus("Connection renamed!", "success");
338
+ connection.name = newName.trim();
339
+ } else {
340
+ showStatus(`Rename failed: ${result.error}`, "error");
341
+ }
342
+ await pause();
343
+ }
344
+ return true;
345
+ }
346
+ },
347
+ {
348
+ label: "Test Connection",
349
+ action: async () => {
350
+ showStatus("Testing connection...", "info");
351
+ const result = await api.testConnection(connection.id);
352
+ if (result.success) {
353
+ showStatus("Connection is working!", "success");
354
+ } else {
355
+ showStatus(`Test failed: ${result.error}`, "error");
356
+ }
357
+ await pause();
358
+ return true;
359
+ }
360
+ },
361
+ {
362
+ label: "Delete Connection",
363
+ action: async () => {
364
+ const confirmed = await confirm(`Delete connection "${name}"?`);
365
+ if (confirmed) {
366
+ const result = await api.deleteConnection(connection.id);
367
+ if (result.success) {
368
+ showStatus("Connection deleted!", "success");
369
+ } else {
370
+ showStatus(`Delete failed: ${result.error}`, "error");
371
+ }
372
+ await pause();
373
+ return false; // Exit menu after delete
374
+ }
375
+ return true;
376
+ }
377
+ }
378
+ ]
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Handle adding new connection
384
+ * @param {string} providerId - Provider ID
385
+ * @param {string} authType - "oauth" or "apikey"
386
+ */
387
+ // Providers that use Device Code Flow (terminal-based polling)
388
+ const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"];
389
+
390
+ /**
391
+ * Handle adding new connection - auto-detect flow type
392
+ * @param {string} providerId - Provider ID
393
+ * @param {string} authType - "oauth" or "apikey"
394
+ */
395
+ async function handleAddConnection(providerId, authType) {
396
+ if (authType === "apikey") {
397
+ await handleAddApiKeyConnection(providerId);
398
+ } else {
399
+ // OAuth: auto-detect flow type based on provider
400
+ if (DEVICE_CODE_PROVIDERS.includes(providerId)) {
401
+ // Device Code Flow for GitHub, Qwen, Kiro
402
+ await handleAddDeviceCodeConnection(providerId);
403
+ } else {
404
+ // Authorization Code Flow for Claude, Codex, Gemini, etc.
405
+ await handleAddOAuthConnection(providerId);
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Handle adding API Key connection
412
+ * @param {string} providerId - Provider ID
413
+ */
414
+ async function handleAddApiKeyConnection(providerId) {
415
+ clearScreen();
416
+ const provider = ALL_PROVIDERS[providerId];
417
+ console.log(`\n➕ Add ${provider.name} API Key Connection\n`);
418
+
419
+ const name = await prompt("Connection Name: ");
420
+ if (!name) {
421
+ showStatus("Cancelled", "warning");
422
+ await pause();
423
+ return;
424
+ }
425
+
426
+ const apiKey = await prompt("API Key: ");
427
+ if (!apiKey) {
428
+ showStatus("Cancelled", "warning");
429
+ await pause();
430
+ return;
431
+ }
432
+
433
+ showStatus("Creating connection...", "info");
434
+
435
+ const result = await api.createApiKeyProvider({
436
+ provider: providerId,
437
+ name,
438
+ apiKey
439
+ });
440
+
441
+ if (result.success) {
442
+ showStatus("✓ Connection created successfully!", "success");
443
+ } else {
444
+ showStatus(`✗ Failed: ${result.error}`, "error");
445
+ }
446
+
447
+ await pause();
448
+ }
449
+
450
+ /**
451
+ * Handle adding OAuth Authorization Code connection
452
+ * User opens URL manually and pastes callback URL
453
+ * @param {string} providerId - Provider ID
454
+ */
455
+ async function handleAddOAuthConnection(providerId) {
456
+ clearScreen();
457
+ const provider = ALL_PROVIDERS[providerId];
458
+
459
+ // Step 1: Get auth URL
460
+ showStatus("Requesting authorization URL...", "info");
461
+ const authResult = await api.getOAuthAuthUrl(providerId);
462
+
463
+ if (!authResult.success) {
464
+ showStatus(`Failed: ${authResult.error}`, "error");
465
+ await pause();
466
+ return;
467
+ }
468
+
469
+ const authData = authResult.data || authResult;
470
+ const authUrl = authData.authUrl;
471
+ const codeVerifier = authData.codeVerifier;
472
+ const state = authData.state;
473
+ const redirectUri = authData.redirectUri;
474
+
475
+ if (!authUrl) {
476
+ showStatus("Failed: No auth URL received", "error");
477
+ await pause();
478
+ return;
479
+ }
480
+
481
+ // Step 2: Show URL and instructions
482
+ clearScreen();
483
+ showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`);
484
+
485
+ console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`);
486
+ console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`);
487
+ if (copyToClipboard(authUrl)) {
488
+ console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
489
+ }
490
+ console.log();
491
+ console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`);
492
+ console.log();
493
+ console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`);
494
+ console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`);
495
+ console.log();
496
+
497
+ const callbackUrl = await prompt(" Paste callback URL: ");
498
+ if (!callbackUrl) {
499
+ showStatus("Cancelled", "warning");
500
+ await pause();
501
+ return;
502
+ }
503
+
504
+ // Step 3: Parse callback URL and extract code
505
+ let code, urlState, error;
506
+ try {
507
+ const url = new URL(callbackUrl.trim());
508
+ code = url.searchParams.get("code");
509
+ urlState = url.searchParams.get("state");
510
+ error = url.searchParams.get("error");
511
+
512
+ if (error) {
513
+ const errorDesc = url.searchParams.get("error_description") || error;
514
+ showStatus(`Authorization failed: ${errorDesc}`, "error");
515
+ await pause();
516
+ return;
517
+ }
518
+
519
+ if (!code) {
520
+ showStatus("No authorization code found in URL", "error");
521
+ await pause();
522
+ return;
523
+ }
524
+ } catch (err) {
525
+ showStatus("Invalid URL format", "error");
526
+ await pause();
527
+ return;
528
+ }
529
+
530
+ // Step 4: Exchange code for tokens
531
+ console.log();
532
+ showStatus("Exchanging code for tokens...", "info");
533
+ const exchangeResult = await api.exchangeOAuthCode(providerId, {
534
+ code,
535
+ redirectUri,
536
+ codeVerifier,
537
+ state: urlState || state
538
+ });
539
+
540
+ if (exchangeResult.success) {
541
+ showStatus("Connection created successfully!", "success");
542
+ } else {
543
+ showStatus(`Failed: ${exchangeResult.error}`, "error");
544
+ }
545
+
546
+ await pause();
547
+ }
548
+
549
+ /**
550
+ * Handle adding OAuth Device Code connection
551
+ * @param {string} providerId - Provider ID
552
+ */
553
+ async function handleAddDeviceCodeConnection(providerId) {
554
+ clearScreen();
555
+ const provider = ALL_PROVIDERS[providerId];
556
+
557
+ // Step 1: Request device code
558
+ showStatus("Requesting device code...", "info");
559
+ const deviceResult = await api.getOAuthDeviceCode(providerId);
560
+
561
+ if (!deviceResult.success) {
562
+ showStatus(`Failed: ${deviceResult.error}`, "error");
563
+ await pause();
564
+ return;
565
+ }
566
+
567
+ const deviceData = deviceResult.data || deviceResult;
568
+ const device_code = deviceData.device_code;
569
+ const user_code = deviceData.user_code;
570
+ const verification_uri = deviceData.verification_uri;
571
+ const verification_uri_complete = deviceData.verification_uri_complete;
572
+ const codeVerifier = deviceData.codeVerifier;
573
+ const extraData = deviceData.extraData || deviceData;
574
+
575
+ if (!device_code) {
576
+ showStatus("Failed: No device code received", "error");
577
+ await pause();
578
+ return;
579
+ }
580
+
581
+ // Step 2: Show instructions
582
+ clearScreen();
583
+ const deviceUrl = verification_uri_complete || verification_uri;
584
+ showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`);
585
+
586
+ console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`);
587
+ if (copyToClipboard(deviceUrl)) {
588
+ console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
589
+ }
590
+ console.log();
591
+ if (!verification_uri_complete && user_code) {
592
+ console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`);
593
+ console.log();
594
+ }
595
+ console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`);
596
+ console.log();
597
+
598
+ // Step 3: Poll for token
599
+ const maxAttempts = 60; // 5 minutes (5s interval)
600
+ for (let i = 0; i < maxAttempts; i++) {
601
+ await new Promise(resolve => setTimeout(resolve, 5000));
602
+
603
+ const pollResult = await api.pollOAuthToken(providerId, {
604
+ deviceCode: device_code,
605
+ codeVerifier,
606
+ extraData
607
+ });
608
+
609
+ if (pollResult.success) {
610
+ showStatus("\nConnection created successfully!", "success");
611
+ await pause();
612
+ return;
613
+ }
614
+
615
+ // Check if still pending (pending flag is at root level, not in data)
616
+ const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down";
617
+ if (!isPending) {
618
+ showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error");
619
+ await pause();
620
+ return;
621
+ }
622
+
623
+ process.stdout.write(".");
624
+ }
625
+
626
+ showStatus("\nTimeout waiting for authorization", "error");
627
+ await pause();
628
+ }
629
+
630
+ // ============================================================================
631
+ // CUSTOM PROVIDERS (provider nodes)
632
+ // ============================================================================
633
+
634
+ const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"];
635
+ const OPENAI_API_TYPES = ["chat", "responses"];
636
+
637
+ /**
638
+ * Show custom providers section in main providers menu
639
+ * @param {Array} nodes - List of provider nodes
640
+ * @param {Array} connections - All connections
641
+ * @param {Array<string>} breadcrumb
642
+ */
643
+ async function showCustomProvidersMenu(breadcrumb = []) {
644
+ const { showListMenu } = require("../utils/menuHelper");
645
+
646
+ await showListMenu({
647
+ title: "🔧 Custom Providers",
648
+ breadcrumb,
649
+ backLabel: "← Back to Providers",
650
+ fetchItems: async () => {
651
+ const res = await api.getProviderNodes();
652
+ if (!res.success) return { items: [] };
653
+ return { items: res.data.nodes || res.data || [] };
654
+ },
655
+ formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`,
656
+ onSelect: async (node) => {
657
+ await showCustomNodeDetail(node, [...breadcrumb, node.name]);
658
+ },
659
+ createAction: {
660
+ label: "➕ Add Custom Provider",
661
+ action: async () => {
662
+ await handleAddCustomNode();
663
+ }
664
+ }
665
+ });
666
+ }
667
+
668
+ /**
669
+ * Show detail menu for a custom provider node
670
+ */
671
+ async function showCustomNodeDetail(node, breadcrumb = []) {
672
+ await showMenuWithBack({
673
+ title: `🔧 ${node.name}`,
674
+ breadcrumb,
675
+ headerContent: [
676
+ `Type: ${node.type}`,
677
+ `Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`,
678
+ `Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`,
679
+ ].join("\n"),
680
+ items: [
681
+ {
682
+ label: "Connections",
683
+ action: async () => {
684
+ await showCustomNodeConnections(node, breadcrumb);
685
+ return true;
686
+ }
687
+ },
688
+ {
689
+ label: "Edit Node",
690
+ action: async () => {
691
+ await handleEditCustomNode(node);
692
+ return true;
693
+ }
694
+ },
695
+ {
696
+ label: "Delete Node",
697
+ action: async () => {
698
+ const confirmed = await confirm(`Delete "${node.name}" and all its connections?`);
699
+ if (confirmed) {
700
+ const res = await api.deleteProviderNode(node.id);
701
+ if (res.success) {
702
+ showStatus("Node deleted!", "success");
703
+ } else {
704
+ showStatus(`Delete failed: ${res.error}`, "error");
705
+ }
706
+ await pause();
707
+ return false;
708
+ }
709
+ return true;
710
+ }
711
+ }
712
+ ]
713
+ });
714
+ }
715
+
716
+ /**
717
+ * Show connections for a custom provider node
718
+ */
719
+ async function showCustomNodeConnections(node, breadcrumb = []) {
720
+ const { showListMenu } = require("../utils/menuHelper");
721
+
722
+ await showListMenu({
723
+ title: `🔌 ${node.name} – Connections`,
724
+ breadcrumb,
725
+ backLabel: "← Back",
726
+ fetchItems: async () => {
727
+ const res = await api.getProviders();
728
+ if (!res.success) return { items: [] };
729
+ const all = res.data.connections || [];
730
+ const items = all.filter(c => c.provider === node.id);
731
+ return { items };
732
+ },
733
+ formatItem: (conn) => {
734
+ const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
735
+ return `${conn.name || "Unnamed"} (${status})`;
736
+ },
737
+ onSelect: async (conn) => {
738
+ await showConnectionActions(conn, node.id, breadcrumb);
739
+ },
740
+ createAction: {
741
+ label: "Add API Key Connection",
742
+ action: async () => {
743
+ await handleAddCustomNodeConnection(node);
744
+ }
745
+ }
746
+ });
747
+ }
748
+
749
+ /**
750
+ * Add API key connection to a custom provider node
751
+ */
752
+ async function handleAddCustomNodeConnection(node) {
753
+ clearScreen();
754
+ console.log(`\n➕ Add Connection to ${node.name}\n`);
755
+
756
+ const name = await prompt("Connection Name: ");
757
+ if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
758
+
759
+ const apiKey = await prompt("API Key: ");
760
+ if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; }
761
+
762
+ showStatus("Creating connection...", "info");
763
+ const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey });
764
+
765
+ showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
766
+ await pause();
767
+ }
768
+
769
+ /**
770
+ * Handle adding a new custom provider node
771
+ */
772
+ async function handleAddCustomNode() {
773
+ clearScreen();
774
+ console.log("\n➕ Add Custom Provider\n");
775
+
776
+ // Step 1: Select type
777
+ const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
778
+ console.log(`Select type:\n${typeChoices}\n`);
779
+ const typeInput = await prompt("Type (1/2): ");
780
+ const typeIdx = parseInt(typeInput) - 1;
781
+ if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) {
782
+ showStatus("Cancelled", "warning"); await pause(); return;
783
+ }
784
+ const type = CUSTOM_NODE_TYPES[typeIdx];
785
+
786
+ // Step 2: Inputs
787
+ const name = await prompt("Name: ");
788
+ if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
789
+
790
+ const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): ");
791
+ if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; }
792
+
793
+ const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): ");
794
+ if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; }
795
+
796
+ // Step 3: API type (OpenAI only)
797
+ let apiType;
798
+ if (type === "openai-compatible") {
799
+ const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
800
+ console.log(`\nAPI Type:\n${apiTypeChoices}\n`);
801
+ const apiTypeInput = await prompt("API Type (1/2, default 1): ");
802
+ const apiTypeIdx = parseInt(apiTypeInput) - 1;
803
+ apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat";
804
+ }
805
+
806
+ showStatus("Creating provider node...", "info");
807
+ const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) };
808
+ const res = await api.createProviderNode(body);
809
+
810
+ showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
811
+ await pause();
812
+ }
813
+
814
+ /**
815
+ * Handle editing a custom provider node
816
+ */
817
+ async function handleEditCustomNode(node) {
818
+ clearScreen();
819
+ console.log(`\n✏️ Edit ${node.name}\n`);
820
+ console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`);
821
+
822
+ const name = await prompt(`Name (${node.name}): `);
823
+ const baseUrl = await prompt(`Base URL (${node.baseUrl}): `);
824
+ const prefix = await prompt(`Prefix (${node.prefix}): `);
825
+
826
+ const updates = {};
827
+ if (name && name.trim()) updates.name = name.trim();
828
+ if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim();
829
+ if (prefix && prefix.trim()) updates.prefix = prefix.trim();
830
+
831
+ if (!Object.keys(updates).length) {
832
+ showStatus("No changes", "warning"); await pause(); return;
833
+ }
834
+
835
+ showStatus("Updating...", "info");
836
+ const res = await api.updateProviderNode(node.id, updates);
837
+ if (res.success) {
838
+ Object.assign(node, updates);
839
+ showStatus("✓ Updated!", "success");
840
+ } else {
841
+ showStatus(`✗ Failed: ${res.error}`, "error");
842
+ }
843
+ await pause();
844
+ }
845
+
846
+ module.exports = { showProvidersMenu };
cli/src/cli/menus/settings.js ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+ const { confirm, pause } = require("../utils/input");
3
+ const { showStatus } = require("../utils/display");
4
+ const { showMenuWithBack } = require("../utils/menuHelper");
5
+
6
+ // ANSI colors
7
+ const COLORS = {
8
+ reset: "\x1b[0m",
9
+ green: "\x1b[32m",
10
+ red: "\x1b[31m",
11
+ yellow: "\x1b[33m",
12
+ dim: "\x1b[2m",
13
+ cyan: "\x1b[36m"
14
+ };
15
+
16
+ const DEFAULT_PASSWORD = "123456";
17
+
18
+ /**
19
+ * Show settings menu (tunnel + RTK + reset password)
20
+ * @param {Array<string>} breadcrumb - Breadcrumb path
21
+ */
22
+ async function showSettingsMenu(breadcrumb = []) {
23
+ await showMenuWithBack({
24
+ title: "⚙️ Settings",
25
+ breadcrumb,
26
+ headerContent: async (data) => {
27
+ const lines = [];
28
+
29
+ // Tunnel section
30
+ const tunnel = data?.tunnel || {};
31
+ if (tunnel.enabled && tunnel.publicUrl) {
32
+ lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
33
+ lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
34
+ } else {
35
+ lines.push(` Endpoint: http://localhost:20128/v1`);
36
+ lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
37
+ }
38
+
39
+ // RTK section
40
+ const rtkOn = data?.settings?.rtkEnabled !== false;
41
+ lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`);
42
+
43
+ // Auth mode section
44
+ const authMode = data?.settings?.authMode || "password";
45
+ const authColor = authMode === "password" ? COLORS.green : COLORS.yellow;
46
+ lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`);
47
+
48
+ return lines.join("\n");
49
+ },
50
+ refresh: async () => {
51
+ const [tunnelRes, settingsRes] = await Promise.all([
52
+ api.getTunnelStatus(),
53
+ api.getSettings()
54
+ ]);
55
+ return {
56
+ tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
57
+ settings: settingsRes.success ? (settingsRes.data || {}) : {}
58
+ };
59
+ },
60
+ items: [
61
+ {
62
+ label: "Tunnel ON",
63
+ action: async () => { await enableTunnel(); return true; }
64
+ },
65
+ {
66
+ label: "Tunnel OFF",
67
+ action: async () => { await disableTunnel(); return true; }
68
+ },
69
+ {
70
+ label: (d) => {
71
+ const on = d?.settings?.rtkEnabled !== false;
72
+ return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`;
73
+ },
74
+ action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; }
75
+ },
76
+ {
77
+ label: "🔑 Reset Password to Default",
78
+ action: async () => { await resetPassword(); return true; }
79
+ },
80
+ {
81
+ label: (d) => {
82
+ const mode = d?.settings?.authMode || "password";
83
+ return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`;
84
+ },
85
+ action: async () => { await resetAuthMode(); return true; }
86
+ }
87
+ ]
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Reset authMode to "password" via API. Used when OIDC is misconfigured
93
+ * and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token.
94
+ */
95
+ async function resetAuthMode() {
96
+ const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?");
97
+ if (!ok) {
98
+ showStatus("Cancelled", "info");
99
+ await pause();
100
+ return;
101
+ }
102
+
103
+ const result = await api.updateSettings({ authMode: "password" });
104
+ if (result.success) {
105
+ showStatus("Auth mode reset to password. OIDC disabled.", "success");
106
+ } else {
107
+ showStatus(`Failed: ${result.error}`, "error");
108
+ }
109
+ await pause();
110
+ }
111
+
112
+ /**
113
+ * Enable tunnel via API
114
+ */
115
+ async function enableTunnel() {
116
+ showStatus("Creating tunnel...", "info");
117
+ const result = await api.enableTunnel();
118
+
119
+ if (result.success) {
120
+ const { publicUrl, shortId, alreadyRunning } = result.data || {};
121
+ if (alreadyRunning) {
122
+ showStatus(`Tunnel already running: ${publicUrl}`, "success");
123
+ } else {
124
+ showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success");
125
+ }
126
+ } else {
127
+ showStatus(`Failed: ${result.error}`, "error");
128
+ }
129
+
130
+ await pause();
131
+ }
132
+
133
+ /**
134
+ * Disable tunnel via API
135
+ */
136
+ async function disableTunnel() {
137
+ const result = await api.disableTunnel();
138
+
139
+ if (result.success) {
140
+ showStatus("Tunnel disabled", "success");
141
+ } else {
142
+ showStatus(`Failed: ${result.error}`, "error");
143
+ }
144
+
145
+ await pause();
146
+ }
147
+
148
+ /**
149
+ * Toggle RTK (Token Saver) via API
150
+ * @param {boolean} currentlyOn
151
+ */
152
+ async function toggleRtk(currentlyOn) {
153
+ const next = !currentlyOn;
154
+ const result = await api.updateSettings({ rtkEnabled: next });
155
+ if (result.success) {
156
+ showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success");
157
+ } else {
158
+ showStatus(`Failed: ${result.error}`, "error");
159
+ }
160
+ await pause();
161
+ }
162
+
163
+ /**
164
+ * Reset dashboard password to default via server API (writes the live SQLite DB).
165
+ * After reset, user can log in with the default password "123456".
166
+ */
167
+ async function resetPassword() {
168
+ const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`);
169
+ if (!ok) {
170
+ showStatus("Cancelled", "info");
171
+ await pause();
172
+ return;
173
+ }
174
+
175
+ const result = await api.resetPassword();
176
+ if (result.success) {
177
+ showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success");
178
+ } else {
179
+ showStatus(`Failed to reset password: ${result.error}`, "error");
180
+ }
181
+ await pause();
182
+ }
183
+
184
+ module.exports = { showSettingsMenu };
cli/src/cli/terminalUI.js ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("./api/client");
2
+ const { showMenuWithBack } = require("./utils/menuHelper");
3
+ const { showProvidersMenu } = require("./menus/providers");
4
+ const { showApiKeysMenu } = require("./menus/apiKeys");
5
+ const { showCombosMenu } = require("./menus/combos");
6
+ const { showSettingsMenu } = require("./menus/settings");
7
+ const { showCliToolsMenu } = require("./menus/cliTools");
8
+
9
+ const COLORS = {
10
+ reset: "\x1b[0m",
11
+ green: "\x1b[32m",
12
+ red: "\x1b[31m",
13
+ dim: "\x1b[2m",
14
+ cyan: "\x1b[36m"
15
+ };
16
+
17
+ // Cached header (SWR): show last value instantly, refresh in background.
18
+ let cachedHeader = "";
19
+ let fetchingHeader = false;
20
+
21
+ function renderHeader(port, keys, tunnel) {
22
+ const tunnelEnabled = tunnel && tunnel.enabled === true;
23
+ const lines = [];
24
+ if (tunnelEnabled && tunnel.publicUrl) {
25
+ lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
26
+ lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
27
+ } else {
28
+ lines.push(`Endpoint: http://localhost:${port}/v1`);
29
+ lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
30
+ }
31
+ if (!keys || keys.length === 0) {
32
+ lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
33
+ } else {
34
+ lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`);
35
+ keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`));
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+
40
+ async function refreshHeaderBg(port) {
41
+ if (fetchingHeader) return;
42
+ fetchingHeader = true;
43
+ try {
44
+ const [keysResult, tunnelResult] = await Promise.all([
45
+ api.getApiKeys(),
46
+ api.getTunnelStatus()
47
+ ]);
48
+ const keys = keysResult.success ? (keysResult.data.keys || []) : [];
49
+ const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
50
+ cachedHeader = renderHeader(port, keys, tunnel);
51
+ } finally {
52
+ fetchingHeader = false;
53
+ }
54
+ }
55
+
56
+ function getHeader(port) {
57
+ // Kick off background refresh; return cache (or placeholder on first call).
58
+ refreshHeaderBg(port);
59
+ return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`;
60
+ }
61
+
62
+ /**
63
+ * Start Terminal UI
64
+ * @param {number} port - Server port number
65
+ */
66
+ async function startTerminalUI(port) {
67
+ // Configure API client
68
+ api.configure({ port });
69
+
70
+ const basePath = ["9Router"];
71
+
72
+ // Prime header cache before first render
73
+ await refreshHeaderBg(port);
74
+
75
+ // Main menu
76
+ await showMenuWithBack({
77
+ title: "📡 9Router Terminal UI",
78
+ breadcrumb: basePath,
79
+ headerContent: () => getHeader(port),
80
+ items: [
81
+ {
82
+ label: "Providers",
83
+ action: async () => {
84
+ await showProvidersMenu([...basePath, "Providers"]);
85
+ return true; // Continue
86
+ }
87
+ },
88
+ {
89
+ label: "API Keys",
90
+ action: async () => {
91
+ await showApiKeysMenu(port, [...basePath, "API Keys"]);
92
+ return true;
93
+ }
94
+ },
95
+ {
96
+ label: "Combos",
97
+ action: async () => {
98
+ await showCombosMenu([...basePath, "Combos"]);
99
+ return true;
100
+ }
101
+ },
102
+ {
103
+ label: "CLI Tools",
104
+ action: async () => {
105
+ await showCliToolsMenu(port, [...basePath, "CLI Tools"]);
106
+ return true;
107
+ }
108
+ },
109
+ {
110
+ label: "Settings",
111
+ action: async () => {
112
+ await showSettingsMenu([...basePath, "Settings"]);
113
+ return true;
114
+ }
115
+ }
116
+ ],
117
+ backLabel: "← Back to Interface Menu"
118
+ });
119
+ }
120
+
121
+ module.exports = { startTerminalUI };
cli/src/cli/tray/autostart.js ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { execSync } = require("child_process");
5
+
6
+ const APP_NAME = "9router";
7
+ const APP_LABEL = "com.9router.autostart";
8
+
9
+ /**
10
+ * Resolve the absolute path to this package's cli.js.
11
+ *
12
+ * Order of preference:
13
+ * 1. Explicit `cliPath` argument — cleanest, used when called from running
14
+ * cli.js with `__filename`.
15
+ * 2. `process.argv[1]` if it's our cli.js — true when 9router is currently
16
+ * running and the tray menu fires this code path.
17
+ * 3. Compute relative to this file's own location. autostart.js lives at
18
+ * `<pkg>/src/cli/tray/autostart.js`, so cli.js is three levels up.
19
+ * This works for any global install layout (nvm, Volta, asdf, Homebrew,
20
+ * /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9)
21
+ * or a hardcoded `/usr/local/...` path.
22
+ *
23
+ * Returns null if no candidate exists — callers should not write an autostart
24
+ * entry pointing at a non-existent script.
25
+ */
26
+ function getCliJsPath(cliPath) {
27
+ if (cliPath) {
28
+ const resolved = path.resolve(cliPath);
29
+ if (fs.existsSync(resolved)) return resolved;
30
+ }
31
+ if (process.argv[1]) {
32
+ const resolved = path.resolve(process.argv[1]);
33
+ if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) {
34
+ return resolved;
35
+ }
36
+ }
37
+ const computed = path.resolve(__dirname, "..", "..", "..", "cli.js");
38
+ if (fs.existsSync(computed)) return computed;
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Enable auto startup on OS boot
44
+ * @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
45
+ * @returns {boolean} success
46
+ */
47
+ function enableAutoStart(cliPath) {
48
+ const platform = process.platform;
49
+
50
+ if (!["darwin", "win32", "linux"].includes(platform)) return false;
51
+ if (platform === "linux" && !process.env.DISPLAY) return false;
52
+
53
+ try {
54
+ if (platform === "darwin") return enableMacOS(cliPath);
55
+ if (platform === "win32") return enableWindows(cliPath);
56
+ if (platform === "linux") return enableLinux(cliPath);
57
+ } catch (err) {
58
+ // Silent fail — autostart is optional
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * Disable auto startup
65
+ * @returns {boolean} success
66
+ */
67
+ function disableAutoStart() {
68
+ const platform = process.platform;
69
+ try {
70
+ if (platform === "darwin") return disableMacOS();
71
+ if (platform === "win32") return disableWindows();
72
+ if (platform === "linux") return disableLinux();
73
+ } catch (err) {}
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Check if autostart is enabled.
79
+ *
80
+ * On macOS, both the plist file and the launchd registration must be present —
81
+ * otherwise the tray menu would lie about the state (showing "✓ Enabled" even
82
+ * when launchd has the agent in a failed state or hasn't loaded it).
83
+ */
84
+ function isAutoStartEnabled() {
85
+ const platform = process.platform;
86
+
87
+ try {
88
+ if (platform === "darwin") {
89
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
90
+ if (!fs.existsSync(plistPath)) return false;
91
+ try {
92
+ execSync(`launchctl list ${APP_LABEL}`, {
93
+ stdio: ["ignore", "ignore", "ignore"],
94
+ timeout: 3000
95
+ });
96
+ return true;
97
+ } catch (e) {
98
+ return false;
99
+ }
100
+ } else if (platform === "win32") {
101
+ const startupPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
102
+ return fs.existsSync(startupPath);
103
+ } else if (platform === "linux") {
104
+ const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
105
+ return fs.existsSync(desktopPath);
106
+ }
107
+ } catch (e) {}
108
+ return false;
109
+ }
110
+
111
+ // ============ macOS ============
112
+
113
+ /**
114
+ * Returns true when the current Node process IS the running instance that
115
+ * launchd is managing under our agent label.
116
+ *
117
+ * `launchctl unload <plist>` (and `load`) for an Aqua user-domain agent sends
118
+ * SIGTERM to the running process. When the running 9router cli.js was itself
119
+ * spawned by the autostart launchd agent (i.e. user enabled autostart at
120
+ * some point, then rebooted, then clicked the tray icon's "Disable
121
+ * Auto-start" menu item), an unload would kill the very process executing
122
+ * the click handler — and the tray icon would disappear instead of the menu
123
+ * label flipping back to "Enable Auto-start". This helper lets the enable
124
+ * and disable paths sidestep that by skipping launchctl when we'd otherwise
125
+ * be killing ourselves.
126
+ */
127
+ function isAgentSelfMacOS() {
128
+ try {
129
+ const output = execSync(`launchctl list ${APP_LABEL}`, {
130
+ encoding: "utf8",
131
+ stdio: ["ignore", "pipe", "ignore"],
132
+ timeout: 3000
133
+ });
134
+ const match = output.match(/"PID"\s*=\s*(\d+)/);
135
+ return !!(match && parseInt(match[1], 10) === process.pid);
136
+ } catch (e) {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function enableMacOS(cliPath) {
142
+ const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
143
+ const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
144
+
145
+ if (!fs.existsSync(launchAgentsDir)) {
146
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
147
+ }
148
+
149
+ const nodePath = process.execPath;
150
+ const routerScript = getCliJsPath(cliPath);
151
+ // Don't write a broken plist that references a non-existent script.
152
+ if (!routerScript) return false;
153
+
154
+ // Invoke node + cli.js directly with absolute paths — no shell wrapper.
155
+ // The previous design ran `zsh -l -c "..."` so a login shell would source
156
+ // nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user,
157
+ // some setups don't put node on PATH from a non-interactive login shell).
158
+ // EnvironmentVariables.PATH explicitly includes node's bin dir so child
159
+ // processes spawned by cli.js (npm install at runtime, etc.) resolve.
160
+ const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`;
161
+
162
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
163
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
164
+ <plist version="1.0">
165
+ <dict>
166
+ <key>Label</key>
167
+ <string>${APP_LABEL}</string>
168
+ <key>ProgramArguments</key>
169
+ <array>
170
+ <string>${nodePath}</string>
171
+ <string>${routerScript}</string>
172
+ <string>--tray</string>
173
+ <string>--skip-update</string>
174
+ </array>
175
+ <key>EnvironmentVariables</key>
176
+ <dict>
177
+ <key>PATH</key>
178
+ <string>${launchPath}</string>
179
+ </dict>
180
+ <key>RunAtLoad</key>
181
+ <true/>
182
+ <key>KeepAlive</key>
183
+ <false/>
184
+ <key>StandardOutPath</key>
185
+ <string>/tmp/9router.log</string>
186
+ <key>StandardErrorPath</key>
187
+ <string>/tmp/9router.error.log</string>
188
+ </dict>
189
+ </plist>`;
190
+
191
+ fs.writeFileSync(plistPath, plistContent);
192
+
193
+ // If we're the running agent already, launchctl unload/load would send
194
+ // ourselves SIGTERM. Skip it — the plist file is updated on disk and
195
+ // launchd will pick it up at next login. isAutoStartEnabled() will still
196
+ // return true because launchctl already has the agent loaded.
197
+ if (isAgentSelfMacOS()) {
198
+ return true;
199
+ }
200
+
201
+ // Register with launchd in the current session. Without this, the agent
202
+ // only takes effect on the next user login and the user has no signal that
203
+ // anything actually happened. `unload` first defends against re-enable
204
+ // replacing an existing plist.
205
+ try {
206
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
207
+ } catch (e) {}
208
+ try {
209
+ execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" });
210
+ } catch (e) {
211
+ // Even if load fails, the plist is on disk and will be picked up at next
212
+ // login; report success based on the file write.
213
+ }
214
+ return true;
215
+ }
216
+
217
+ function disableMacOS() {
218
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
219
+
220
+ // Don't kill ourselves: when the current process is the running agent,
221
+ // `launchctl unload` would send SIGTERM and the user clicking
222
+ // "Disable Auto-start" from the tray menu would lose their tray icon
223
+ // instead of just flipping the menu label. Skip the unload — removing the
224
+ // plist file is enough to prevent the agent from starting on next login.
225
+ if (!isAgentSelfMacOS()) {
226
+ try {
227
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
228
+ } catch (e) {}
229
+ }
230
+
231
+ if (fs.existsSync(plistPath)) {
232
+ fs.unlinkSync(plistPath);
233
+ }
234
+ return true;
235
+ }
236
+
237
+ // ============ Windows ============
238
+
239
+ function enableWindows(cliPath) {
240
+ const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
241
+ const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
242
+
243
+ if (!fs.existsSync(startupDir)) return false;
244
+
245
+ const nodePath = process.execPath;
246
+ const routerScript = getCliJsPath(cliPath);
247
+ if (!routerScript) return false;
248
+
249
+ // Run node + cli.js directly, hidden window. Avoids the fragile
250
+ // `9router.cmd` lookup that depended on the npm prefix path.
251
+ const vbsContent = `Set WshShell = CreateObject("WScript.Shell")
252
+ WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
253
+ `;
254
+ fs.writeFileSync(vbsPath, vbsContent);
255
+ return true;
256
+ }
257
+
258
+ function disableWindows() {
259
+ const vbsPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
260
+ if (fs.existsSync(vbsPath)) {
261
+ fs.unlinkSync(vbsPath);
262
+ }
263
+ return true;
264
+ }
265
+
266
+ // ============ Linux ============
267
+
268
+ function enableLinux(cliPath) {
269
+ const autostartDir = path.join(os.homedir(), ".config", "autostart");
270
+ const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
271
+
272
+ if (!fs.existsSync(autostartDir)) {
273
+ try { fs.mkdirSync(autostartDir, { recursive: true }); }
274
+ catch (e) { return false; }
275
+ }
276
+
277
+ const nodePath = process.execPath;
278
+ const routerScript = getCliJsPath(cliPath);
279
+ if (!routerScript) return false;
280
+
281
+ const desktopContent = `[Desktop Entry]
282
+ Type=Application
283
+ Name=9Router
284
+ Comment=9Router API Proxy
285
+ Exec=${nodePath} ${routerScript} --tray --skip-update
286
+ Hidden=false
287
+ NoDisplay=false
288
+ X-GNOME-Autostart-enabled=true
289
+ `;
290
+ fs.writeFileSync(desktopPath, desktopContent);
291
+ return true;
292
+ }
293
+
294
+ function disableLinux() {
295
+ const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
296
+ if (fs.existsSync(desktopPath)) {
297
+ fs.unlinkSync(desktopPath);
298
+ }
299
+ return true;
300
+ }
301
+
302
+ module.exports = {
303
+ enableAutoStart,
304
+ disableAutoStart,
305
+ isAutoStartEnabled
306
+ };
cli/src/cli/tray/icon.ico ADDED
cli/src/cli/tray/icon.png ADDED
cli/src/cli/tray/tray.js ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { exec } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ let trayInstance = null;
6
+ let isWinTray = false;
7
+
8
+ /**
9
+ * Get icon base64 from file — used for systray (mac/linux)
10
+ */
11
+ function getIconBase64() {
12
+ const isWin = process.platform === "win32";
13
+ const iconFile = isWin ? "icon.ico" : "icon.png";
14
+ try {
15
+ const iconPath = path.join(__dirname, iconFile);
16
+ if (fs.existsSync(iconPath)) {
17
+ return fs.readFileSync(iconPath).toString("base64");
18
+ }
19
+ } catch (e) {}
20
+ // Fallback: minimal green dot icon (PNG)
21
+ return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC";
22
+ }
23
+
24
+ /**
25
+ * Check if system tray is supported on current OS
26
+ * Supported: macOS, Windows, Linux (with GUI)
27
+ */
28
+ function isTraySupported() {
29
+ const platform = process.platform;
30
+ if (!["darwin", "win32", "linux"].includes(platform)) {
31
+ return false;
32
+ }
33
+ if (platform === "linux" && !process.env.DISPLAY) {
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /**
40
+ * Initialize system tray with menu
41
+ * @param {Object} options - { port, onQuit, onOpenDashboard }
42
+ * @returns {Object|null} tray instance or null if not supported/failed
43
+ */
44
+ function initTray(options) {
45
+ if (!isTraySupported()) {
46
+ return null;
47
+ }
48
+
49
+ // Windows uses PowerShell NotifyIcon (AV-safe), others use systray
50
+ if (process.platform === "win32") {
51
+ return initWindowsTray(options);
52
+ }
53
+ return initUnixTray(options);
54
+ }
55
+
56
+ /**
57
+ * Build menu items array shared between platforms
58
+ */
59
+ function buildMenuItems(port, autostartEnabled) {
60
+ return [
61
+ { title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false },
62
+ { title: "Open Dashboard", tooltip: "Open in browser", enabled: true },
63
+ {
64
+ title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
65
+ tooltip: "Run on OS startup",
66
+ enabled: true
67
+ },
68
+ { title: "Quit", tooltip: "Stop server and exit", enabled: true }
69
+ ];
70
+ }
71
+
72
+ // Menu item indexes
73
+ const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 };
74
+
75
+ /**
76
+ * Get current autostart state
77
+ */
78
+ function getAutostartEnabled() {
79
+ try {
80
+ const { isAutoStartEnabled } = require("./autostart");
81
+ return isAutoStartEnabled();
82
+ } catch (e) {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Handle menu item click (shared logic)
89
+ */
90
+ function handleClick(index, options, onAutostartToggle) {
91
+ const { onQuit, onOpenDashboard, port } = options;
92
+ if (index === MENU_INDEX.DASHBOARD) {
93
+ if (onOpenDashboard) onOpenDashboard();
94
+ else openBrowser(`http://localhost:${port}/dashboard`);
95
+ } else if (index === MENU_INDEX.AUTOSTART) {
96
+ const enabled = getAutostartEnabled();
97
+ try {
98
+ const { enableAutoStart, disableAutoStart } = require("./autostart");
99
+ if (enabled) disableAutoStart();
100
+ else enableAutoStart();
101
+ onAutostartToggle(!enabled);
102
+ } catch (e) {}
103
+ } else if (index === MENU_INDEX.QUIT) {
104
+ console.log("\n👋 Shutting down...");
105
+ if (onQuit) onQuit();
106
+ killTray();
107
+ setTimeout(() => process.exit(0), 500);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Windows tray via PowerShell NotifyIcon
113
+ */
114
+ function initWindowsTray(options) {
115
+ const { port } = options;
116
+ try {
117
+ const { initWinTray } = require("./trayWin");
118
+ const iconPath = path.join(__dirname, "icon.ico");
119
+ const autostartEnabled = getAutostartEnabled();
120
+ const items = buildMenuItems(port, autostartEnabled);
121
+
122
+ trayInstance = initWinTray({
123
+ iconPath,
124
+ tooltip: `9Router - Port ${port}`,
125
+ items,
126
+ onClick: (index) => {
127
+ handleClick(index, options, (newEnabled) => {
128
+ const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start";
129
+ trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true);
130
+ });
131
+ }
132
+ });
133
+
134
+ isWinTray = true;
135
+ return trayInstance;
136
+ } catch (err) {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * macOS/Linux tray via systray binary
143
+ *
144
+ * Prefers `systray2` (active fork of `systray`, ships newer
145
+ * getlantern/systray-portable binaries that work on macOS 14+ and Apple
146
+ * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2
147
+ * is not available, though that binary's Mach-O headers are rejected by
148
+ * modern dyld and the icon will not appear.
149
+ */
150
+ function resolveSystray() {
151
+ let runtimeDir = null;
152
+ try {
153
+ const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
154
+ runtimeDir = getRuntimeNodeModules();
155
+ } catch (e) {}
156
+
157
+ // 1) systray2 in runtime dir (where ensureTrayRuntime installs it)
158
+ if (runtimeDir) {
159
+ try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {}
160
+ }
161
+ // 2) systray2 resolvable from the package's own node_modules / NODE_PATH
162
+ try { return { mod: require("systray2").default, isV2: true }; } catch (e) {}
163
+ // 3) Legacy systray fallback (unlikely to render on modern macOS)
164
+ try { return { mod: require("systray").default, isV2: false }; } catch (e) {}
165
+ if (runtimeDir) {
166
+ try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {}
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function chmodTrayBin(pkgName) {
172
+ // systray2's npm tarball occasionally lands without +x on the bundled Go
173
+ // binary (observed on macOS). spawn() then fails with EACCES. Best-effort
174
+ // chmod on every init avoids a hard-to-diagnose silent tray failure.
175
+ try {
176
+ const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
177
+ const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
178
+ const candidates = [
179
+ path.join(getRuntimeNodeModules(), pkgName, "traybin", binName),
180
+ path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName)
181
+ ];
182
+ for (const p of candidates) {
183
+ if (fs.existsSync(p)) fs.chmodSync(p, 0o755);
184
+ }
185
+ } catch (e) {}
186
+ }
187
+
188
+ function initUnixTray(options) {
189
+ const { port } = options;
190
+ try {
191
+ const resolved = resolveSystray();
192
+ if (!resolved) return null;
193
+ const { mod: SysTray, isV2 } = resolved;
194
+
195
+ chmodTrayBin(isV2 ? "systray2" : "systray");
196
+
197
+ const autostartEnabled = getAutostartEnabled();
198
+ const items = buildMenuItems(port, autostartEnabled);
199
+
200
+ const menu = {
201
+ icon: getIconBase64(),
202
+ // The bundled icon.png is a full-color RGBA logo. Don't mark it as a
203
+ // template icon: macOS would then render it as a solid white square
204
+ // because template mode only uses the alpha channel.
205
+ isTemplateIcon: false,
206
+ title: "",
207
+ tooltip: `9Router - Port ${port}`,
208
+ items
209
+ };
210
+
211
+ trayInstance = new SysTray({ menu, debug: false, copyDir: true });
212
+ isWinTray = false;
213
+
214
+ trayInstance.onClick((action) => {
215
+ handleClick(action.seq_id, options, (newEnabled) => {
216
+ trayInstance.sendAction({
217
+ type: "update-item",
218
+ item: {
219
+ title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
220
+ tooltip: "Run on OS startup",
221
+ enabled: true
222
+ },
223
+ seq_id: MENU_INDEX.AUTOSTART
224
+ });
225
+ });
226
+ });
227
+
228
+ if (isV2) {
229
+ // systray2 exposes a ready() promise instead of onReady/onError. Surface
230
+ // failures (binary crash, EACCES, etc.) so users can see why the icon
231
+ // didn't appear instead of getting a misleading "running in tray" log.
232
+ trayInstance.ready().catch((err) => {
233
+ process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`);
234
+ });
235
+ } else {
236
+ trayInstance.onReady(() => {});
237
+ trayInstance.onError(() => {});
238
+ }
239
+
240
+ return trayInstance;
241
+ } catch (err) {
242
+ process.stderr.write(`[9router] tray init error: ${err.message}\n`);
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Kill tray, wait Go binary fully exit (returns Promise).
249
+ * Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess
250
+ * spawns a new tray, otherwise the new icon silently fails to register.
251
+ */
252
+ function killTray() {
253
+ const instance = trayInstance;
254
+ const wasWin = isWinTray;
255
+ trayInstance = null;
256
+ if (!instance) return Promise.resolve();
257
+
258
+ if (wasWin) {
259
+ try { instance.kill(); } catch (e) {}
260
+ return Promise.resolve();
261
+ }
262
+
263
+ // Unix: get the Go tray child process handle.
264
+ let proc = null;
265
+ try {
266
+ proc = instance._process || (typeof instance.process === "function" ? instance.process() : null);
267
+ } catch (e) {}
268
+
269
+ // Graceful shutdown: send {type:"exit"} via IPC so the Go binary can call
270
+ // systray.Quit() and release NSStatusItem. SIGKILL leaves a ghost icon on
271
+ // the macOS menubar until logout, causing duplicate icons after re-spawn.
272
+ const gracefulQuit = () => { try { instance.kill(true); } catch (e) {} };
273
+ const closeIpc = () => { try { instance.kill(false); } catch (e) {} };
274
+
275
+ if (!proc || !proc.pid) {
276
+ gracefulQuit();
277
+ closeIpc();
278
+ return Promise.resolve();
279
+ }
280
+
281
+ return new Promise((resolve) => {
282
+ let done = false;
283
+ const finish = () => { if (done) return; done = true; closeIpc(); resolve(); };
284
+
285
+ proc.once("exit", finish);
286
+ gracefulQuit();
287
+
288
+ // Escalate: SIGTERM after 800ms, SIGKILL after 1600ms if still alive.
289
+ setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGTERM"); } catch (e) {} }, 800);
290
+ setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGKILL"); } catch (e) {} }, 1600);
291
+
292
+ // Fallback poll in case "exit" never fires (detached child, pipe closed)
293
+ const deadline = Date.now() + 3000;
294
+ const poll = setInterval(() => {
295
+ try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; }
296
+ if (Date.now() > deadline) { clearInterval(poll); finish(); }
297
+ }, 50);
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Open browser
303
+ */
304
+ function openBrowser(url) {
305
+ const platform = process.platform;
306
+ let cmd;
307
+
308
+ if (platform === "darwin") {
309
+ cmd = `open "${url}"`;
310
+ } else if (platform === "win32") {
311
+ cmd = `start "" "${url}"`;
312
+ } else {
313
+ cmd = `xdg-open "${url}"`;
314
+ }
315
+
316
+ exec(cmd);
317
+ }
318
+
319
+ module.exports = {
320
+ initTray,
321
+ killTray
322
+ };
cli/src/cli/tray/tray.ps1 ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 9Router tray icon for Windows using NotifyIcon
2
+ # IPC: stdin JSON commands, stdout JSON events
3
+ param([string]$IconPath, [string]$Tooltip)
4
+
5
+ Add-Type -AssemblyName System.Windows.Forms
6
+ Add-Type -AssemblyName System.Drawing
7
+
8
+ $ErrorActionPreference = "Stop"
9
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
10
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
11
+ $OutputEncoding = [System.Text.Encoding]::UTF8
12
+
13
+ $script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
14
+ $script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
15
+ $script:notifyIcon.Text = $Tooltip
16
+ $script:notifyIcon.Visible = $true
17
+
18
+ $script:menu = New-Object System.Windows.Forms.ContextMenuStrip
19
+ $script:notifyIcon.ContextMenuStrip = $script:menu
20
+ $script:items = @()
21
+
22
+ function Write-Event($obj) {
23
+ $json = $obj | ConvertTo-Json -Compress
24
+ [Console]::Out.WriteLine($json)
25
+ [Console]::Out.Flush()
26
+ }
27
+
28
+ function Add-MenuItem($index, $title, $enabled) {
29
+ $item = New-Object System.Windows.Forms.ToolStripMenuItem
30
+ $item.Text = $title
31
+ $item.Enabled = $enabled
32
+ $idx = $index
33
+ $item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure())
34
+ $script:menu.Items.Add($item) | Out-Null
35
+ $script:items += $item
36
+ }
37
+
38
+ function Update-MenuItem($index, $title, $enabled) {
39
+ if ($index -lt $script:items.Count) {
40
+ $script:items[$index].Text = $title
41
+ $script:items[$index].Enabled = $enabled
42
+ }
43
+ }
44
+
45
+ function Set-Tooltip($text) {
46
+ # NotifyIcon.Text max 63 chars
47
+ if ($text.Length -gt 63) { $text = $text.Substring(0, 63) }
48
+ $script:notifyIcon.Text = $text
49
+ }
50
+
51
+ # Background reader thread polls stdin via timer on UI thread
52
+ $script:timer = New-Object System.Windows.Forms.Timer
53
+ $script:timer.Interval = 100
54
+ $script:timer.Add_Tick({
55
+ try {
56
+ while ([Console]::In.Peek() -ne -1) {
57
+ $line = [Console]::In.ReadLine()
58
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
59
+ $cmd = $line | ConvertFrom-Json
60
+ switch ($cmd.action) {
61
+ "add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled }
62
+ "update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled }
63
+ "set-tooltip" { Set-Tooltip $cmd.text }
64
+ "ready" { Write-Event @{ type = "ready" } }
65
+ "kill" {
66
+ $script:notifyIcon.Visible = $false
67
+ $script:notifyIcon.Dispose()
68
+ [System.Windows.Forms.Application]::Exit()
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ Write-Event @{ type = "error"; message = $_.Exception.Message }
74
+ }
75
+ })
76
+ $script:timer.Start()
77
+
78
+ Write-Event @{ type = "started" }
79
+ [System.Windows.Forms.Application]::Run()
cli/src/cli/tray/trayWin.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { spawn } = require("child_process");
2
+ const path = require("path");
3
+ const readline = require("readline");
4
+
5
+ // PowerShell-based tray for Windows (AV-safe, zero binary deps)
6
+
7
+ let psProcess = null;
8
+ let clickHandler = null;
9
+
10
+ /**
11
+ * Send JSON command to PowerShell tray process via stdin
12
+ */
13
+ function sendCommand(cmd) {
14
+ if (psProcess && psProcess.stdin.writable) {
15
+ psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8");
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Initialize Windows tray using PowerShell NotifyIcon
21
+ * @param {Object} options - { iconPath, tooltip, items, onClick }
22
+ * items: [{ title, enabled }]
23
+ * @returns {Object|null} controller with sendAction/kill
24
+ */
25
+ function initWinTray(options) {
26
+ const { iconPath, tooltip, items, onClick } = options;
27
+ clickHandler = onClick;
28
+
29
+ const scriptPath = path.join(__dirname, "tray.ps1");
30
+
31
+ try {
32
+ psProcess = spawn(
33
+ "powershell.exe",
34
+ [
35
+ "-NoProfile",
36
+ "-ExecutionPolicy", "Bypass",
37
+ "-WindowStyle", "Hidden",
38
+ "-InputFormat", "Text",
39
+ "-OutputFormat", "Text",
40
+ "-File", scriptPath,
41
+ "-IconPath", iconPath,
42
+ "-Tooltip", tooltip
43
+ ],
44
+ { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }
45
+ );
46
+ } catch (err) {
47
+ return null;
48
+ }
49
+
50
+ const rl = readline.createInterface({ input: psProcess.stdout });
51
+ rl.on("line", (line) => {
52
+ try {
53
+ const evt = JSON.parse(line);
54
+ if (evt.type === "click" && clickHandler) {
55
+ clickHandler(evt.index);
56
+ }
57
+ } catch (e) {}
58
+ });
59
+
60
+ psProcess.on("error", () => {});
61
+ psProcess.stderr.on("data", () => {});
62
+
63
+ // Send initial menu items
64
+ items.forEach((item, index) => {
65
+ sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled });
66
+ });
67
+
68
+ return {
69
+ updateItem(index, title, enabled) {
70
+ sendCommand({ action: "update-item", index, title, enabled });
71
+ },
72
+ setTooltip(text) {
73
+ sendCommand({ action: "set-tooltip", text });
74
+ },
75
+ kill() {
76
+ try {
77
+ sendCommand({ action: "kill" });
78
+ } catch (e) {}
79
+ setTimeout(() => {
80
+ if (psProcess && !psProcess.killed) {
81
+ try { psProcess.kill(); } catch (e) {}
82
+ }
83
+ psProcess = null;
84
+ }, 300);
85
+ }
86
+ };
87
+ }
88
+
89
+ module.exports = { initWinTray };
cli/src/cli/utils/clipboard.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { execSync } = require("child_process");
2
+
3
+ /**
4
+ * Copy text to clipboard based on OS
5
+ * @param {string} text - Text to copy
6
+ * @returns {boolean} Success status
7
+ */
8
+ function copyToClipboard(text) {
9
+ try {
10
+ const platform = process.platform;
11
+
12
+ if (platform === "darwin") {
13
+ execSync("pbcopy", { input: text });
14
+ } else if (platform === "win32") {
15
+ execSync("clip", { input: text });
16
+ } else {
17
+ // Linux - try xclip first, then xsel
18
+ try {
19
+ execSync("xclip -selection clipboard", { input: text });
20
+ } catch {
21
+ execSync("xsel --clipboard --input", { input: text });
22
+ }
23
+ }
24
+ return true;
25
+ } catch (error) {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ module.exports = { copyToClipboard };
cli/src/cli/utils/display.js ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { formatNumber } = require("./format");
2
+
3
+ // ANSI color codes
4
+ const COLORS = {
5
+ reset: "\x1b[0m",
6
+ success: "\x1b[32m",
7
+ error: "\x1b[31m",
8
+ warning: "\x1b[33m",
9
+ info: "\x1b[36m",
10
+ dim: "\x1b[2m",
11
+ bold: "\x1b[1m",
12
+ bright: "\x1b[1m",
13
+ cyan: "\x1b[36m"
14
+ };
15
+
16
+ // Box drawing characters
17
+ const BOX_CHARS = {
18
+ topLeft: "┌",
19
+ topRight: "┐",
20
+ bottomLeft: "└",
21
+ bottomRight: "┘",
22
+ horizontal: "─",
23
+ vertical: "│"
24
+ };
25
+
26
+ /**
27
+ * Draw a box with border around content
28
+ * @param {string} title - Box title
29
+ * @param {string} content - Content to display inside box
30
+ * @param {number} [width=60] - Box width
31
+ */
32
+ function showBox(title, content, width = 60) {
33
+ const innerWidth = width - 4;
34
+ const lines = content.split("\n");
35
+
36
+ // Top border with title
37
+ const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) +
38
+ ` ${title} ` +
39
+ BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) +
40
+ BOX_CHARS.topRight;
41
+
42
+ console.log(topBorder);
43
+
44
+ // Content lines
45
+ lines.forEach(line => {
46
+ const paddedLine = line.padEnd(innerWidth);
47
+ console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`);
48
+ });
49
+
50
+ // Bottom border
51
+ const bottomBorder = BOX_CHARS.bottomLeft +
52
+ BOX_CHARS.horizontal.repeat(innerWidth + 2) +
53
+ BOX_CHARS.bottomRight;
54
+
55
+ console.log(bottomBorder);
56
+ }
57
+
58
+ /**
59
+ * Display a menu with numbered items
60
+ * @param {string} title - Menu title
61
+ * @param {string[]} items - Array of menu items
62
+ * @param {string} [footer] - Optional footer text
63
+ */
64
+ function showMenu(title, items, footer) {
65
+ console.log(`\n${COLORS.bold}${title}${COLORS.reset}`);
66
+ console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset);
67
+
68
+ items.forEach((item, index) => {
69
+ console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`);
70
+ });
71
+
72
+ if (footer) {
73
+ console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`);
74
+ }
75
+ console.log();
76
+ }
77
+
78
+ /**
79
+ * Display data in table format
80
+ * @param {string[]} headers - Array of column headers
81
+ * @param {Array<Array<string|number>>} rows - Array of row data
82
+ */
83
+ function showTable(headers, rows) {
84
+ if (!headers.length || !rows.length) {
85
+ return;
86
+ }
87
+
88
+ // Calculate column widths
89
+ const colWidths = headers.map((header, i) => {
90
+ const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length));
91
+ return Math.max(header.length, maxDataWidth);
92
+ });
93
+
94
+ // Print header
95
+ const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ ");
96
+ console.log(COLORS.bold + headerRow + COLORS.reset);
97
+
98
+ // Print separator
99
+ const separator = colWidths.map(w => "─".repeat(w)).join("─┼─");
100
+ console.log(COLORS.dim + separator + COLORS.reset);
101
+
102
+ // Print rows
103
+ rows.forEach(row => {
104
+ const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ ");
105
+ console.log(rowStr);
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Show colored status message
111
+ * @param {string} message - Message to display
112
+ * @param {string} [type="info"] - Status type: success, error, warning, info
113
+ */
114
+ function showStatus(message, type = "info") {
115
+ const symbols = {
116
+ success: "✓",
117
+ error: "✗",
118
+ warning: "⚠",
119
+ info: "ℹ"
120
+ };
121
+
122
+ const color = COLORS[type] || COLORS.info;
123
+ const symbol = symbols[type] || symbols.info;
124
+
125
+ console.log(`${color}${symbol} ${message}${COLORS.reset}`);
126
+ }
127
+
128
+ /**
129
+ * Clear the terminal screen
130
+ */
131
+ function clearScreen() {
132
+ console.clear();
133
+ }
134
+
135
+ /**
136
+ * Show menu header with title and subtitle
137
+ * @param {string} title - Main title
138
+ * @param {string} subtitle - Optional subtitle
139
+ */
140
+ function showHeader(title, subtitle) {
141
+ console.log(`\n${"=".repeat(60)}`);
142
+ console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
143
+ if (subtitle) {
144
+ console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
145
+ }
146
+ console.log(`${"=".repeat(60)}\n`);
147
+ }
148
+
149
+ module.exports = {
150
+ showBox,
151
+ showMenu,
152
+ showTable,
153
+ showStatus,
154
+ clearScreen,
155
+ showHeader
156
+ };
cli/src/cli/utils/endpoint.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+
3
+ const COLORS = {
4
+ reset: "\x1b[0m",
5
+ green: "\x1b[32m"
6
+ };
7
+
8
+ /**
9
+ * Get endpoint URL based on tunnel status
10
+ * @param {number} port - Local server port
11
+ * @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
12
+ */
13
+ async function getEndpoint(port) {
14
+ const result = await api.getTunnelStatus();
15
+ const tunnelEnabled = result.success && result.data?.enabled === true;
16
+ const publicUrl = result.success ? result.data?.publicUrl : "";
17
+
18
+ const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
19
+ return { endpoint, tunnelEnabled };
20
+ }
21
+
22
+ /**
23
+ * Get endpoint with color formatting
24
+ * @param {number} port - Local server port
25
+ * @returns {Promise<string>} Colored endpoint string
26
+ */
27
+ async function getEndpointColored(port) {
28
+ const { endpoint, tunnelEnabled } = await getEndpoint(port);
29
+ return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
30
+ }
31
+
32
+ module.exports = { getEndpoint, getEndpointColored };
cli/src/cli/utils/format.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Truncate text with ellipsis
3
+ * @param {string} text - Text to truncate
4
+ * @param {number} maxLength - Maximum length
5
+ * @returns {string} Truncated text
6
+ */
7
+ function truncate(text, maxLength) {
8
+ if (!text || text.length <= maxLength) {
9
+ return text;
10
+ }
11
+ return text.substring(0, maxLength - 3) + "...";
12
+ }
13
+
14
+ /**
15
+ * Mask API key showing only first and last characters
16
+ * @param {string} key - API key to mask
17
+ * @returns {string} Masked key
18
+ */
19
+ function maskKey(key) {
20
+ if (!key || key.length < 8) {
21
+ return "***";
22
+ }
23
+ const firstChars = key.substring(0, 4);
24
+ const lastChars = key.substring(key.length - 4);
25
+ return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`;
26
+ }
27
+
28
+ /**
29
+ * Format date to readable string
30
+ * @param {Date|string|number} date - Date to format
31
+ * @returns {string} Formatted date string
32
+ */
33
+ function formatDate(date) {
34
+ const d = new Date(date);
35
+ if (isNaN(d.getTime())) {
36
+ return "Invalid Date";
37
+ }
38
+
39
+ const year = d.getFullYear();
40
+ const month = String(d.getMonth() + 1).padStart(2, "0");
41
+ const day = String(d.getDate()).padStart(2, "0");
42
+ const hours = String(d.getHours()).padStart(2, "0");
43
+ const minutes = String(d.getMinutes()).padStart(2, "0");
44
+ const seconds = String(d.getSeconds()).padStart(2, "0");
45
+
46
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
47
+ }
48
+
49
+ /**
50
+ * Format number with commas
51
+ * @param {number} num - Number to format
52
+ * @returns {string} Formatted number
53
+ */
54
+ function formatNumber(num) {
55
+ if (typeof num !== "number" || isNaN(num)) {
56
+ return "0";
57
+ }
58
+ return num.toLocaleString("en-US");
59
+ }
60
+
61
+ /**
62
+ * Format bytes to human readable size
63
+ * @param {number} bytes - Bytes to format
64
+ * @returns {string} Formatted size string
65
+ */
66
+ function formatBytes(bytes) {
67
+ if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
68
+ return "0 B";
69
+ }
70
+
71
+ const units = ["B", "KB", "MB", "GB", "TB"];
72
+ let size = bytes;
73
+ let unitIndex = 0;
74
+
75
+ while (size >= 1024 && unitIndex < units.length - 1) {
76
+ size /= 1024;
77
+ unitIndex++;
78
+ }
79
+
80
+ return `${size.toFixed(2)} ${units[unitIndex]}`;
81
+ }
82
+
83
+ /**
84
+ * Get relative time string
85
+ * @param {Date|string|number} date - Date to compare
86
+ * @returns {string} Relative time string
87
+ */
88
+ function getRelativeTime(date) {
89
+ const d = new Date(date);
90
+ if (isNaN(d.getTime())) {
91
+ return "Invalid Date";
92
+ }
93
+
94
+ const now = new Date();
95
+ const diffMs = now - d;
96
+ const diffSec = Math.floor(diffMs / 1000);
97
+ const diffMin = Math.floor(diffSec / 60);
98
+ const diffHour = Math.floor(diffMin / 60);
99
+ const diffDay = Math.floor(diffHour / 24);
100
+ const diffMonth = Math.floor(diffDay / 30);
101
+ const diffYear = Math.floor(diffDay / 365);
102
+
103
+ if (diffSec < 60) {
104
+ return "just now";
105
+ } else if (diffMin < 60) {
106
+ return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
107
+ } else if (diffHour < 24) {
108
+ return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
109
+ } else if (diffDay < 30) {
110
+ return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
111
+ } else if (diffMonth < 12) {
112
+ return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`;
113
+ } else {
114
+ return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`;
115
+ }
116
+ }
117
+
118
+ module.exports = {
119
+ truncate,
120
+ maskKey,
121
+ formatDate,
122
+ formatNumber,
123
+ formatBytes,
124
+ getRelativeTime
125
+ };
cli/src/cli/utils/input.js ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const readline = require("readline");
2
+
3
+ const COLORS = {
4
+ reset: "\x1b[0m",
5
+ bright: "\x1b[1m",
6
+ dim: "\x1b[2m",
7
+ underline: "\x1b[4m",
8
+ reverse: "\x1b[7m",
9
+ cyan: "\x1b[36m",
10
+ green: "\x1b[32m",
11
+ yellow: "\x1b[33m",
12
+ blue: "\x1b[34m",
13
+ white: "\x1b[37m",
14
+ bgGreen: "\x1b[42m",
15
+ bgBlue: "\x1b[44m",
16
+ black: "\x1b[30m",
17
+ terracotta: "\x1b[38;2;217;119;87m",
18
+ bgTerracotta: "\x1b[48;2;217;119;87m"
19
+ };
20
+
21
+ // Prime stdin once globally. Toggling raw mode between menus adds latency on
22
+ // macOS, so we keep raw mode on for the whole TUI session.
23
+ let rawPrimed = false;
24
+ function primeRawOnce() {
25
+ if (rawPrimed || !process.stdin.isTTY) return;
26
+ try {
27
+ readline.emitKeypressEvents(process.stdin);
28
+ process.stdin.setRawMode(true);
29
+ process.stdin.setEncoding("utf8");
30
+ process.stdin.resume();
31
+ rawPrimed = true;
32
+ } catch {}
33
+ }
34
+
35
+ function suspendRawFor(fn) {
36
+ // Temporarily drop raw mode so readline.question can buffer line input.
37
+ const wasPrimed = rawPrimed;
38
+ if (wasPrimed && process.stdin.isTTY) {
39
+ try { process.stdin.setRawMode(false); } catch {}
40
+ }
41
+ return fn().finally(() => {
42
+ if (wasPrimed && process.stdin.isTTY) {
43
+ try { process.stdin.setRawMode(true); } catch {}
44
+ process.stdin.resume();
45
+ }
46
+ });
47
+ }
48
+
49
+ async function prompt(question) {
50
+ return suspendRawFor(() => new Promise((resolve) => {
51
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
52
+ rl.question(question, (answer) => {
53
+ rl.close();
54
+ resolve((answer || "").trim());
55
+ });
56
+ }));
57
+ }
58
+
59
+ async function select(question, options) {
60
+ console.log(question);
61
+ options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
62
+ while (true) {
63
+ const answer = await prompt("\nSelect option (number): ");
64
+ const num = parseInt(answer, 10);
65
+ if (!isNaN(num) && num >= 1 && num <= options.length) return num - 1;
66
+ console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`);
67
+ }
68
+ }
69
+
70
+ async function confirm(question) {
71
+ while (true) {
72
+ const answer = await prompt(`${question} (y/n): `);
73
+ const lower = answer.toLowerCase();
74
+ if (lower === "y" || lower === "yes") return true;
75
+ if (lower === "n" || lower === "no") return false;
76
+ console.log("Please answer 'y' or 'n'");
77
+ }
78
+ }
79
+
80
+ async function pause(message = "Press Enter to continue...") {
81
+ return suspendRawFor(() => new Promise((resolve) => {
82
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
83
+ rl.question(message, () => { rl.close(); resolve(); });
84
+ }));
85
+ }
86
+
87
+ /**
88
+ * Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright
89
+ * (no underline). Uses readline keypress + raw 'data' fallback to prevent
90
+ * arrow-key escape sequence leaks on macOS.
91
+ */
92
+ async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
93
+ return new Promise((resolve) => {
94
+ let selectedIndex = defaultIndex;
95
+ let isActive = true;
96
+
97
+ primeRawOnce();
98
+ if (!process.stdin.isTTY) { resolve(-1); return; }
99
+
100
+ const renderMenu = () => {
101
+ if (!isActive) return;
102
+ process.stdout.write("\x1b[2J\x1b[H");
103
+ const width = Math.min(process.stdout.columns || 40, 40);
104
+ console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
105
+ console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`);
106
+ if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
107
+ console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
108
+ if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
109
+ console.log();
110
+ if (headerContent) { console.log(headerContent); console.log(); }
111
+
112
+ const isWin = process.platform === "win32";
113
+ items.forEach((item, index) => {
114
+ const isSelected = index === selectedIndex;
115
+ const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
116
+ if (isSelected) {
117
+ console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
118
+ } else {
119
+ console.log(` ${icon} ${item.label}`);
120
+ }
121
+ });
122
+ };
123
+
124
+ const cleanup = () => {
125
+ if (!isActive) return;
126
+ isActive = false;
127
+ process.stdin.removeListener("keypress", onKeypress);
128
+ };
129
+
130
+ const move = (delta) => {
131
+ selectedIndex = (selectedIndex + delta + items.length) % items.length;
132
+ renderMenu();
133
+ };
134
+
135
+ const onKeypress = (_str, key) => {
136
+ if (!isActive || !key) return;
137
+ if (key.name === "up") return move(-1);
138
+ if (key.name === "down") return move(1);
139
+ if (key.name === "return") { cleanup(); resolve(selectedIndex); return; }
140
+ if (key.name === "escape") { cleanup(); resolve(-1); return; }
141
+ if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); }
142
+ };
143
+
144
+ process.stdin.on("keypress", onKeypress);
145
+ renderMenu();
146
+ });
147
+ }
148
+
149
+ module.exports = {
150
+ prompt,
151
+ select,
152
+ confirm,
153
+ pause,
154
+ selectMenu,
155
+ COLORS
156
+ };
cli/src/cli/utils/menuHelper.js ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { selectMenu } = require("./input");
2
+
3
+ /**
4
+ * Show a menu with back button at top and handle selection
5
+ * @param {Object} config - Menu configuration
6
+ * @param {string} config.title - Menu title
7
+ * @param {string} config.headerContent - Optional header content
8
+ * @param {Array<{label: string, action: Function}>} config.items - Menu items with actions
9
+ * @param {string} config.backLabel - Back button label (default: "← Back")
10
+ * @param {number} config.defaultIndex - Default selected index (default: 0)
11
+ * @param {Function} config.refresh - Optional refresh function to call after each action
12
+ * @param {Array<string>} config.breadcrumb - Optional breadcrumb path
13
+ * @returns {Promise<void>}
14
+ */
15
+ async function showMenuWithBack(config) {
16
+ const {
17
+ title,
18
+ headerContent = "",
19
+ items,
20
+ backLabel = "← Back",
21
+ defaultIndex = 0,
22
+ refresh = null,
23
+ breadcrumb = []
24
+ } = config;
25
+
26
+ while (true) {
27
+ // Call refresh if provided
28
+ let refreshedData = null;
29
+ if (refresh) {
30
+ refreshedData = await refresh();
31
+ if (refreshedData === null) {
32
+ // Refresh failed, exit menu
33
+ return;
34
+ }
35
+ }
36
+
37
+ // Build menu items with back at top
38
+ const menuItems = [
39
+ { label: backLabel, icon: "☆" },
40
+ ...items.map(item => ({
41
+ label: typeof item.label === "function" ? item.label(refreshedData) : item.label,
42
+ icon: "☆"
43
+ }))
44
+ ];
45
+
46
+ // Resolve headerContent if it's a function
47
+ const resolvedHeader = typeof headerContent === "function"
48
+ ? await headerContent(refreshedData)
49
+ : headerContent;
50
+
51
+ const selected = await selectMenu(
52
+ title,
53
+ menuItems,
54
+ defaultIndex,
55
+ "",
56
+ resolvedHeader,
57
+ breadcrumb
58
+ );
59
+
60
+ // Back or ESC
61
+ if (selected === -1 || selected === 0) {
62
+ return;
63
+ }
64
+
65
+ // Execute action for selected item
66
+ const actionIndex = selected - 1;
67
+ const item = items[actionIndex];
68
+
69
+ if (item && item.action) {
70
+ const shouldContinue = await item.action(refreshedData);
71
+ // If action returns false, exit menu
72
+ if (shouldContinue === false) {
73
+ return;
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Show a list menu where items are fetched dynamically
81
+ * @param {Object} config - Menu configuration
82
+ * @param {string} config.title - Menu title
83
+ * @param {string} config.headerContent - Optional header content
84
+ * @param {Function} config.fetchItems - Async function to fetch items array
85
+ * @param {Function} config.formatItem - Function to format each item to {label, data}
86
+ * @param {Function} config.onSelect - Action when item is selected
87
+ * @param {Object} config.createAction - Optional create action {label, action}
88
+ * @param {string} config.backLabel - Back button label
89
+ * @param {Array<string>} config.breadcrumb - Optional breadcrumb path
90
+ * @returns {Promise<void>}
91
+ */
92
+ async function showListMenu(config) {
93
+ const {
94
+ title,
95
+ headerContent = "",
96
+ fetchItems,
97
+ formatItem,
98
+ onSelect,
99
+ createAction = null,
100
+ backLabel = "← Back",
101
+ breadcrumb = []
102
+ } = config;
103
+
104
+ while (true) {
105
+ // Fetch items
106
+ const result = await fetchItems();
107
+ if (!result) {
108
+ return;
109
+ }
110
+
111
+ const items = result.items || [];
112
+ const metadata = result.metadata || {};
113
+
114
+ // Build menu items
115
+ const menuItems = [{ label: backLabel, icon: "☆" }];
116
+
117
+ if (createAction) {
118
+ menuItems.push({ label: createAction.label, icon: "☆" });
119
+ }
120
+
121
+ items.forEach(item => {
122
+ const formatted = formatItem(item);
123
+ menuItems.push({ label: formatted, icon: "☆" });
124
+ });
125
+
126
+ const header = typeof headerContent === "function"
127
+ ? await headerContent(metadata)
128
+ : headerContent;
129
+
130
+ const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb);
131
+
132
+ // Back or ESC
133
+ if (selected === -1 || selected === 0) {
134
+ return;
135
+ }
136
+
137
+ // Create action
138
+ if (createAction && selected === 1) {
139
+ await createAction.action();
140
+ continue;
141
+ }
142
+
143
+ // Select item
144
+ const offset = createAction ? 2 : 1;
145
+ const itemIndex = selected - offset;
146
+
147
+ if (itemIndex >= 0 && itemIndex < items.length) {
148
+ await onSelect(items[itemIndex]);
149
+ }
150
+ }
151
+ }
152
+
153
+ module.exports = {
154
+ showMenuWithBack,
155
+ showListMenu
156
+ };
cli/src/cli/utils/modelSelector.js ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const api = require("../api/client");
2
+ const { prompt } = require("./input");
3
+ const { clearScreen } = require("./display");
4
+
5
+ // Provider alias order: OAuth first, then API Key (matches ModelSelectModal)
6
+ const PROVIDER_ALIAS_ORDER = [
7
+ "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr",
8
+ "openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
9
+ ];
10
+
11
+ // Alias to display name mapping
12
+ const PROVIDER_ALIAS_NAMES = {
13
+ cc: "Claude Code",
14
+ ag: "Antigravity",
15
+ cx: "OpenAI Codex",
16
+ if: "iFlow AI",
17
+ qw: "Qwen Code",
18
+ gc: "Gemini CLI",
19
+ gh: "GitHub Copilot",
20
+ kr: "Kiro AI",
21
+ openrouter: "OpenRouter",
22
+ glm: "GLM Coding",
23
+ kimi: "Kimi Coding",
24
+ minimax: "Minimax Coding",
25
+ openai: "OpenAI",
26
+ anthropic: "Anthropic",
27
+ gemini: "Gemini"
28
+ };
29
+
30
+ /**
31
+ * Get all available models grouped by provider + combos
32
+ * @returns {Promise<{combos: Array, groups: Object}>}
33
+ */
34
+ async function getAvailableModelsGrouped() {
35
+ const result = await api.getAvailableModels();
36
+ if (!result.success) return { combos: [], groups: {} };
37
+
38
+ const models = result.data?.data || [];
39
+ const combos = [];
40
+ const groups = {};
41
+
42
+ models.forEach(m => {
43
+ if (m.owned_by === "combo") {
44
+ combos.push(m.id);
45
+ } else {
46
+ const provider = m.owned_by;
47
+ if (!groups[provider]) {
48
+ groups[provider] = [];
49
+ }
50
+ groups[provider].push(m.id);
51
+ }
52
+ });
53
+
54
+ return { combos, groups };
55
+ }
56
+
57
+ /**
58
+ * Display model list and prompt for selection
59
+ * @param {string} title - Title to display
60
+ * @param {string} currentValue - Current selected value (optional)
61
+ * @param {Object} options - { excludeCombos?: boolean }
62
+ * @returns {Promise<string|null>} Selected model ID or null if cancelled
63
+ */
64
+ async function selectModelFromList(title, currentValue = "", options = {}) {
65
+ const { excludeCombos = false } = options;
66
+ const { combos: rawCombos, groups } = await getAvailableModelsGrouped();
67
+ const combos = excludeCombos ? [] : rawCombos;
68
+
69
+ const totalModels = combos.length + Object.values(groups).flat().length;
70
+ if (totalModels === 0) {
71
+ return null;
72
+ }
73
+
74
+ // Build flat list for selection
75
+ const allModels = [];
76
+
77
+ // Display
78
+ clearScreen();
79
+ console.log(`\n🎯 ${title}`);
80
+ console.log("=".repeat(50));
81
+ if (currentValue) {
82
+ console.log(`Current: ${currentValue}\n`);
83
+ } else {
84
+ console.log();
85
+ }
86
+
87
+ let idx = 1;
88
+
89
+ // Combos first (skipped when excludeCombos is true)
90
+ if (combos.length > 0) {
91
+ console.log("[Combos]");
92
+ combos.forEach(combo => {
93
+ console.log(` ${idx}. ${combo}`);
94
+ allModels.push(combo);
95
+ idx++;
96
+ });
97
+ console.log();
98
+ }
99
+
100
+ // Provider groups in order (by alias)
101
+ const sortedProviders = Object.keys(groups).sort((a, b) => {
102
+ const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
103
+ const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
104
+ return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
105
+ });
106
+
107
+ sortedProviders.forEach(provider => {
108
+ const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
109
+ console.log(`[${providerName}]`);
110
+ groups[provider].forEach(model => {
111
+ console.log(` ${idx}. ${model}`);
112
+ allModels.push(model);
113
+ idx++;
114
+ });
115
+ console.log();
116
+ });
117
+
118
+ console.log(" 0. Cancel\n");
119
+
120
+ // Prompt for number input
121
+ const input = await prompt("Enter number: ");
122
+ const num = parseInt(input, 10);
123
+
124
+ if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) {
125
+ return null;
126
+ }
127
+
128
+ return allModels[num - 1];
129
+ }
130
+
131
+ module.exports = {
132
+ selectModelFromList,
133
+ getAvailableModelsGrouped,
134
+ PROVIDER_ALIAS_ORDER,
135
+ PROVIDER_ALIAS_NAMES
136
+ };
custom-server.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const http = require("http");
2
+
3
+ const origCreate = http.createServer.bind(http);
4
+
5
+ // Wrap Next standalone HTTP server: derive client IP from the TCP socket
6
+ // (unspoofable) and strip client-supplied forwarding headers so downstream
7
+ // rate-limiting keys on the real peer address instead of attacker-controlled XFF.
8
+ http.createServer = (...args) => {
9
+ const handler = args.find((a) => typeof a === "function");
10
+ const rest = args.filter((a) => typeof a !== "function");
11
+ if (!handler) return origCreate(...args);
12
+ const wrapped = (req, res) => {
13
+ const ip = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : "";
14
+ // Forwarding headers present = request arrived via a reverse proxy; loopback
15
+ // socket is the proxy hop, not the end-user, so it must not be trusted as local.
16
+ const viaProxy = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]);
17
+ delete req.headers["x-9r-real-ip"];
18
+ delete req.headers["x-forwarded-for"];
19
+ delete req.headers["x-9r-via-proxy"];
20
+ req.headers["x-9r-real-ip"] = ip;
21
+ if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
22
+ return handler(req, res);
23
+ };
24
+ return origCreate(...rest, wrapped);
25
+ };
26
+
27
+ require("./server.js");
jsconfig.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": ["./src/*"],
6
+ "open-sse": ["./open-sse"],
7
+ "open-sse/*": ["./open-sse/*"]
8
+ },
9
+ "module": "ESNext",
10
+ "moduleResolution": "bundler"
11
+ }
12
+ }
next.config.mjs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, join } from "node:path";
3
+
4
+ const projectRoot = dirname(fileURLToPath(import.meta.url));
5
+ // CLI bundling needs workspace root so tracing includes hoisted node_modules (slim ~50MB).
6
+ // Docker / default uses projectRoot so server.js lands at /app/server.js (not nested).
7
+ const tracingRoot = process.env.NEXT_TRACING_ROOT_MODE === "workspace"
8
+ ? join(projectRoot, "..")
9
+ : projectRoot;
10
+ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE || "128mb";
11
+
12
+ /** @type {import('next').NextConfig} */
13
+ const nextConfig = {
14
+ distDir: process.env.NEXT_DIST_DIR || ".next",
15
+ output: "standalone",
16
+ serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
17
+ turbopack: {
18
+ root: tracingRoot
19
+ },
20
+ outputFileTracingRoot: tracingRoot,
21
+ outputFileTracingExcludes: {
22
+ "*": ["./gitbook/**/*"]
23
+ },
24
+ images: {
25
+ unoptimized: true
26
+ },
27
+ env: {},
28
+ experimental: {
29
+ // #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites.
30
+ proxyClientMaxBodySize,
31
+ // Cache fetch responses across HMR refreshes for faster dev reloads.
32
+ serverComponentsHmrCache: true,
33
+ },
34
+ webpack: (config, { isServer }) => {
35
+ // Ignore fs/path modules in browser bundle
36
+ if (!isServer) {
37
+ config.resolve.fallback = {
38
+ ...config.resolve.fallback,
39
+ fs: false,
40
+ path: false,
41
+ };
42
+ }
43
+ // Exclude logs, .next, gitbook subapp from watcher
44
+ config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook|cli)[\\/]/ };
45
+ return config;
46
+ },
47
+ async rewrites() {
48
+ return [
49
+ {
50
+ source: "/v1/v1/:path*",
51
+ destination: "/api/v1/:path*"
52
+ },
53
+ {
54
+ source: "/v1/v1",
55
+ destination: "/api/v1"
56
+ },
57
+ {
58
+ source: "/codex/:path*",
59
+ destination: "/api/v1/responses"
60
+ },
61
+ {
62
+ source: "/v1/:path*",
63
+ destination: "/api/v1/:path*"
64
+ },
65
+ {
66
+ source: "/v1",
67
+ destination: "/api/v1"
68
+ }
69
+ ];
70
+ }
71
+ };
72
+
73
+ export default nextConfig;
open-sse/.npmignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ *.log
3
+ .DS_Store
4
+ test/
5
+ *.test.js
6
+ .env
7
+ .env.*
8
+
open-sse/AGENTS.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # open-sse
2
+
3
+ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM chat, image, embedding, tts, stt, search), streamed back in the client's format.
4
+
5
+ ## Request lifecycle (chat)
6
+
7
+ `handlers/chatCore.js` → `services/model.js` `parseModel` (resolve `provider/model`) → `executors/index.js` `getExecutor(provider)` → `translator/index.js` `translateRequest` (client format → provider format) → `executor.execute()` (streams upstream) → `translateResponse` (provider chunks → client format) → SSE out.
8
+
9
+ ## Directory map
10
+
11
+ - `config/` — ALL constants/config (no hardcode elsewhere). `providers.js`/`registry/` (provider defs), `providerModels.js` (alias→models matrix), `runtimeConfig.js` (timeouts, token limits), `*Constants.js`.
12
+ - `translator/` — format conversion. `request/<from>-to-<to>.js`, `response/<from>-to-<to>.js`, `schema/` (enums: ROLE, CLAUDE_BLOCK…), `concerns/` (shared logic), `formats/` (per-format). See `tests/translator/AGENTS.md`.
13
+ - `executors/` — per-provider upstream call. `base.js` (BaseExecutor), one file per special provider, `index.js` map.
14
+ - `providers/` — registry build + `capabilities.js` + `pricing.js`. Entry: `index.js` (PROVIDERS).
15
+ - `handlers/` — per-modality cores (chat/image/embedding/tts/stt/search) + sub-provider folders.
16
+ - `services/` — `tokenRefresh/`, `usage/`, `combo.js`, `accountFallback.js`, `model.js`.
17
+ - `utils/` — streamHandler, error, sessionManager, claudeCloaking.
18
+
19
+ ## Conventions
20
+
21
+ - Config-driven, DRY, camelCase. NEVER hardcode values, models, or block/role strings — use `config/` + `schema/` constants.
22
+ - Translator pipeline pivots through OpenAI as the intermediate format. A translator registered on the exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop.
23
+ - Translators self-register via `register(from, to, reqFn, resFn)` as an import side-effect — new files MUST be imported in `translator/index.js`.
24
+
25
+ ## How to add
26
+
27
+ - **Provider**: copy `providers/REGISTRY_TEMPLATE.js` → `providers/registry/{id}.js`; add models to `config/providerModels.js`. Generic providers need no executor (DefaultExecutor handles OpenAI-compatible APIs).
28
+ - **Executor** (only for non-standard upstream): subclass `BaseExecutor` (override `getBaseUrls`/`buildHeaders`/`buildUrl`/`execute`), register in `executors/index.js` map. `getExecutor` falls back to `DefaultExecutor` when absent.
29
+ - **Translator**: add `request|response/<from>-to-<to>.js` calling `register(...)`, then import it in `translator/index.js`. Reuse `schema/` + `concerns/` — don't re-implement parsing.
30
+
31
+ ## Pitfalls
32
+
33
+ - OpenAI bridge is lossy (thinking, non-base64 images, tool ids, is_error) — prefer a direct route for fragile pairs.
34
+ - `registry/index.js` is an auto-generated static import list; regenerate it (don't hand-edit) after adding a `registry/{id}.js`. REGISTRY_TEMPLATE is excluded by design.
35
+ - Special binary/protobuf formats (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — handle in their executor.
open-sse/config/appConstants.js ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { platform, arch } from "os";
2
+ import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js";
3
+
4
+ // === Gemini CLI === derive từ registry gemini-cli.transport
5
+ export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion;
6
+ export const GEMINI_CLI_API_CLIENT = PROVIDERS["gemini-cli"]?.apiClient;
7
+
8
+ // Map Node arch to Gemini CLI arch string (x64/x86/arm64/...)
9
+ function geminiCLIArch() {
10
+ const a = arch();
11
+ if (a === "ia32") return "x86";
12
+ return a;
13
+ }
14
+
15
+ export function geminiCLIUserAgent(model = "unknown") {
16
+ return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${platform()}; ${geminiCLIArch()}; terminal)`;
17
+ }
18
+
19
+ // === GitHub Copilot ===
20
+ // Derive từ registry github.transport.copilot
21
+ const _ghCopilot = PROVIDERS.github?.copilot || {};
22
+ export const GITHUB_COPILOT = {
23
+ VSCODE_VERSION: _ghCopilot.vscodeVersion,
24
+ COPILOT_CHAT_VERSION: _ghCopilot.chatVersion,
25
+ USER_AGENT: _ghCopilot.userAgent,
26
+ API_VERSION: _ghCopilot.apiVersion,
27
+ };
28
+
29
+ // === Antigravity enums ===
30
+ export const IDE_TYPE = {
31
+ UNSPECIFIED: 0,
32
+ JETSKI: 10,
33
+ ANTIGRAVITY: 9,
34
+ PLUGINS: 7
35
+ };
36
+
37
+ export const PLATFORM = {
38
+ UNSPECIFIED: 0,
39
+ DARWIN_AMD64: 1,
40
+ DARWIN_ARM64: 2,
41
+ LINUX_AMD64: 3,
42
+ LINUX_ARM64: 4,
43
+ WINDOWS_AMD64: 5
44
+ };
45
+
46
+ export const PLUGIN_TYPE = {
47
+ UNSPECIFIED: 0,
48
+ CLOUD_CODE: 1,
49
+ GEMINI: 2
50
+ };
51
+
52
+ export function getPlatformEnum() {
53
+ const os = platform();
54
+ const architecture = arch();
55
+ if (os === "darwin") return architecture === "arm64" ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64;
56
+ if (os === "linux") return architecture === "arm64" ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64;
57
+ if (os === "win32") return PLATFORM.WINDOWS_AMD64;
58
+ return PLATFORM.UNSPECIFIED;
59
+ }
60
+
61
+ export function getPlatformUserAgent() {
62
+ return `antigravity/1.104.0 ${platform()}/${arch()}`;
63
+ }
64
+
65
+ export const CLIENT_METADATA = {
66
+ ideType: IDE_TYPE.ANTIGRAVITY,
67
+ platform: getPlatformEnum(),
68
+ pluginType: PLUGIN_TYPE.GEMINI
69
+ };
70
+
71
+ // Internal anti-loop header
72
+ export const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
73
+
74
+ // Suffix added to client tools when forwarding to Antigravity provider (anti-ban cloaking)
75
+ export const AG_TOOL_SUFFIX = "_ide";
76
+
77
+ // Suffix added to client tools when forwarding to Claude provider (anti-ban cloaking)
78
+ export const CLAUDE_TOOL_SUFFIX = "_ide";
79
+
80
+ // CC native default tools — these are Claude Code's own tools, kept as decoys
81
+ // Client tools matching these names are skipped (not renamed), others get _cc suffix
82
+ export const CC_DEFAULT_TOOLS = new Set([
83
+ "Task",
84
+ "TaskOutput",
85
+ "TaskStop",
86
+ "TaskCreate",
87
+ "TaskGet",
88
+ "TaskUpdate",
89
+ "TaskList",
90
+ "Bash",
91
+ "Glob",
92
+ "Grep",
93
+ "Read",
94
+ "Edit",
95
+ "Write",
96
+ "NotebookEdit",
97
+ "WebFetch",
98
+ "WebSearch",
99
+ "AskUserQuestion",
100
+ "Skill",
101
+ "EnterPlanMode",
102
+ "ExitPlanMode",
103
+ ]);
104
+
105
+ // AG native default tools — kept as decoys with neutral description/properties
106
+ // These names must match exactly what AG sends in the real request log
107
+ export const AG_DEFAULT_TOOLS = new Set([
108
+ "browser_subagent",
109
+ "command_status",
110
+ "find_by_name",
111
+ "generate_image",
112
+ "grep_search",
113
+ "list_dir",
114
+ "list_resources",
115
+ "multi_replace_file_content",
116
+ "notify_user",
117
+ "read_resource",
118
+ "read_terminal",
119
+ "read_url_content",
120
+ "replace_file_content",
121
+ "run_command",
122
+ "search_web",
123
+ "send_command_input",
124
+ "task_boundary",
125
+ "view_content_chunk",
126
+ "view_file",
127
+ "write_to_file"
128
+ ]);
129
+
130
+ // Antigravity chat/stream headers
131
+ export const ANTIGRAVITY_HEADERS = {
132
+ "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`
133
+ };
134
+
135
+ // Cloud Code Assist API
136
+ export const CLOUD_CODE_API = {
137
+ loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
138
+ onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
139
+ };
140
+
141
+ export const LOAD_CODE_ASSIST_HEADERS = {
142
+ "Content-Type": "application/json",
143
+ "User-Agent": "google-api-nodejs-client/9.15.1",
144
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
145
+ "Client-Metadata": JSON.stringify({ ideType: IDE_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), pluginType: PLUGIN_TYPE.GEMINI }),
146
+ };
147
+
148
+ export const LOAD_CODE_ASSIST_METADATA = {
149
+ ideType: IDE_TYPE.ANTIGRAVITY,
150
+ platform: getPlatformEnum(),
151
+ pluginType: PLUGIN_TYPE.GEMINI,
152
+ };
153
+
154
+ // System prompts
155
+ export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
156
+ export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
157
+
158
+ // Derive từ registry oauth.refreshLeadMs
159
+ export const REFRESH_LEAD_MS = Object.fromEntries(
160
+ Object.entries(PROVIDER_OAUTH).filter(([, o]) => o.refreshLeadMs).map(([id, o]) => [id, o.refreshLeadMs])
161
+ );
162
+
163
+ // OAuth endpoints
164
+ export const OAUTH_ENDPOINTS = {
165
+ google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
166
+ openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
167
+ anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
168
+ qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl },
169
+ iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
170
+ github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
171
+ };
172
+
173
+ // Generate Kimi OAuth custom headers
174
+ export function buildKimiHeaders() {
175
+ return {
176
+ "X-Msh-Platform": "9router",
177
+ "X-Msh-Version": "2.1.2",
178
+ "X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown",
179
+ "X-Msh-Device-Id": `kimi-${Date.now()}`
180
+ };
181
+ }
open-sse/config/codexInstructions.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Default instructions for Codex models
2
+
3
+ export const CODEX_DEFAULT_INSTRUCTIONS = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
4
+
5
+ ## General
6
+
7
+ - When searching for text or files, prefer using \`rg\` or \`rg --files\` respectively because \`rg\` is much faster than alternatives like \`grep\`. (If the \`rg\` command is not found, then use alternatives.)
8
+
9
+ ## Editing constraints
10
+
11
+ - Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
12
+ - Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
13
+ - Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
14
+ - You may be in a dirty git worktree.
15
+ * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
16
+ * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
17
+ * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
18
+ * If the changes are in unrelated files, just ignore them and don't revert them.
19
+ - Do not amend a commit unless explicitly requested to do so.
20
+ - While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
21
+ - **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user.
22
+
23
+ ## Plan tool
24
+
25
+ When using the planning tool:
26
+ - Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
27
+ - Do not make single-step plans.
28
+ - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
29
+
30
+ ## Codex CLI harness, sandboxing, and approvals
31
+
32
+ The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
33
+
34
+ Filesystem sandboxing defines which files can be read or written. The options for \`sandbox_mode\` are:
35
+ - **read-only**: The sandbox only permits reading files.
36
+ - **workspace-write**: The sandbox permits reading files, and editing files in \`cwd\` and \`writable_roots\`. Editing files in other directories requires approval.
37
+ - **danger-full-access**: No filesystem sandboxing - all commands are permitted.
38
+
39
+ Network sandboxing defines whether network can be accessed without approval. Options for \`network_access\` are:
40
+ - **restricted**: Requires approval
41
+ - **enabled**: No approval needed
42
+
43
+ Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for \`approval_policy\` are
44
+ - **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
45
+ - **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
46
+ - **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the \`shell\` command description.)
47
+ - **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with \`danger-full-access\`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
48
+
49
+ When you are running with \`approval_policy == on-request\`, and sandboxing enabled, here are scenarios where you'll need to request approval:
50
+ - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
51
+ - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
52
+ - You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
53
+ - If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the \`sandbox_permissions\` and \`justification\` parameters - do not message the user before requesting approval for the command.
54
+ - You are about to take a potentially destructive action such as an \`rm\` or \`git reset\` that the user did not explicitly ask for
55
+ - (for all of these, you should weigh alternative paths that do not require approval)
56
+
57
+ When \`sandbox_mode\` is set to read-only, you'll need to request approval for any command that isn't a read.
58
+
59
+ You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
60
+
61
+ Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
62
+
63
+ When requesting approval to execute a command that will require escalated privileges:
64
+ - Provide the \`sandbox_permissions\` parameter with the value \`"require_escalated"\`
65
+ - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
66
+
67
+ ## Special user requests
68
+
69
+ - If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as \`date\`), you should do so.
70
+ - If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
71
+
72
+ ## Frontend tasks
73
+ When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
74
+ Aim for interfaces that feel intentional, bold, and a bit surprising.
75
+ - Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
76
+ - Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
77
+ - Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
78
+ - Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
79
+ - Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
80
+ - Ensure the page loads properly on both desktop and mobile
81
+
82
+ Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
83
+
84
+ ## Presenting your work and final message
85
+
86
+ You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
87
+
88
+ - Default: be very concise; friendly coding teammate tone.
89
+ - Ask only when needed; suggest ideas; mirror the user's style.
90
+ - For substantial work, summarize clearly; follow final‑answer formatting.
91
+ - Skip heavy formatting for simple confirmations.
92
+ - Don't dump large files you've written; reference paths only.
93
+ - No "save/copy this file" - User is on the same machine.
94
+ - Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
95
+ - For code changes:
96
+ * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
97
+ * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
98
+ * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
99
+ - The user does not command execution outputs. When asked to show the output of a command (e.g. \`git show\`), relay the important details in your answer or summarize the key lines so the user understands the result.
100
+
101
+ ### Final answer structure and style guidelines
102
+
103
+ - Plain text; CLI handles styling. Use structure only when it helps scanability.
104
+ - Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
105
+ - Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
106
+ - Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
107
+ - Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
108
+ - Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
109
+ - Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
110
+ - Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
111
+ - Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
112
+ - File References: When referencing files in your response follow the below rules:
113
+ * Use inline code to make file paths clickable.
114
+ * Each reference should have a stand alone path. Even if it's the same file.
115
+ * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
116
+ * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
117
+ * Do not use URIs like file://, vscode://, or https://.
118
+ * Do not provide range of lines
119
+ * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5`;
open-sse/config/constants.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ // Barrel re-export — consumers can migrate to specific files over time
2
+ export * from "./providers.js";
3
+ export * from "./appConstants.js";
4
+ export * from "./runtimeConfig.js";
open-sse/config/defaultThinkingSignature.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Default signature for thinking mode when no signature from thinkingStore
2
+ export const DEFAULT_THINKING_CLAUDE_SIGNATURE = "EpwGCkYIChgCKkCzVUuRrg7CcglSUWEef4rH6o35g9UYS8ZPe0/VomQTBsFx6sttYNj5l8GqgW6ejuHyYqpFToxIbZl0bw17l5dJEgzCnqDO0Z8fRlMrNgsaDLS1cnCjC53KBqE0CCIwAADQdo1eO+7qPAmo8J4WR3JPmr92S97kmvr5K1iPMiOpkZNj8mEXW8uzBoOJs/9ZKoMFiqHJ3UObwaJDqFOW70E9oCwDoc6jesaWVAEdN5vWfKMpIkjFJjECdjIdkxyJNJ8Ib8yXVal3qwE7uThoPRqSZDdHB5mmwPEjWE/90cSYCbtX2YsJki1265CabBb8/QEkODXg4kgRrL+c8e8rRXz/dr1RswvaPuzEdGKHRNi9UooNUeOK4/ebx1KkP9YZttyohN9GWqlts36kOoW0Cfie/ABDgF9g534BPth/sstxDM6d79QlRmh6NxizyTF74DXJI34u0M4tTRchqE5pAq85SgdJaa+dix1yJPMji8m6nZkwJbscJb9rdc2MKyKWjz8QL2+rTSSuZ2F1k1qSsW0xNcI7qLcI12Vncfn/VqY6YOIZy/saZBR0ezXvN6g+UYbuIdyVg7AyIFZt3nbrO7/kmOEb2VKzygwklHGEIJHfFgMpH3JSrAzbZIowVHOF7VaJ+KXRFDCFin7hHTOiOsdg+1ij1mML9Z/x/9CP4b7OUcaQm1llDZPSHc6rZMNL3DdB+fW5YfmNgKU35S+7AMtA10nVILzDAk1UV4T2K9Do09JlI6rjOs9UuULlIN2Z0eE8YTlANR6uQcw7lMcdfqYE8tke4rDKc2dDiaS5vVe45VewICNpdXGN11yw8QqH7p27CR1HtN30e0tHXOR3bIwWk/Yb6O5fTaKG6Ri8e5ZCPvdD9HqepVi188nM0iTjJqL58F3ni04ECIhcbyaQWnuTes1Kw4CMwiZDLQkk8Hgz7HkUOf1btQTF/0nhD7ry0n0hAEg2PaDM3V6TjOjf4hEldRmeqERcQF1PfgKb6ZM12rlIIfUqKACczWJSzTV158+47HX36o0cgux6nFlv/DE+sEiRVxgB";
3
+
4
+ export const DEFAULT_THINKING_AG_SIGNATURE = "EuwGCukGAXLI2nxwZIq54WWSoL/YN0P3TsDZ7zRnLi8g0S4aVr2HUGxvaHKySuY6HAVzcE0GPGjXrytLIldxthSvfxgUlJh6Qa9Z+Oj5QZBlYdg6HaJ6yuY5R7waE6rdwBsRf7Ft2j3DJ9rMi9qhWFqApewYtPhls3VHtuvND3l8Rm09+lbAXQs6KKWEWrxNLKTBkfpMgXhRERc/TQRMZu1twAablm6/Zk1tsYRvfWKLsNbeKF+CCojJdXJKvnR/8Ouuoa+Y2Ti20hcW7aZIIjZDFYPU//k6Ybmhg69J/imbFai2ckhfLaisqdDkdoIiBJScTOUvYqP6AE9d4MsydSC+UlhIMk4hoP76R8vUSCZRMkjOaDXstf/QoVZKbt94wyRZgAJ1G0BqI8L5ow86kLpA4wJEtxsRGymOE4bKUvApveBakYDNM9APkf+LbtbzWSseGjoZcSlycF9iN8Q2XNYKRrHbv3Lr5Y8JjdH/5y/6SHkNehTEZugaeGnSPSyCTWto1kQgHpxdWmhkLfJGNUGLmue7Mesj4TSms4J33mRpYVhNB/J333FCqIP0hr/E7BkkjEn7yZ4X7SQlh+xKPurapsnHRwiKmtsilmEFrnTE9iQr+pMr6M29qqFNv1tr5yumbaJw8JW9sB15tNsRv+dW6BjNanbsKz7HCgKUBc8tGy+7YuhXzAfViyRefcjK7eZW0Fbyt7AbybJTKz78W8NH7ye6LAwzOebXpeZ4D43fNIt8bKh26qgduSQv/7o+pAflkuqHZ99YWgHQ8h8OkZFi3eOiSYjsjhdZ/czWOdoPI/OnqIldzMPF5YlrKBLFX8VhRKVmqgsmWf5PHGulHhMkVlS+XG2UIseGy69ARa93D78Gsa+1n1kJr7EEB7Rh+27vUMxVYLdz1yMSvE5nalTAlg/ZeG8+XQ0cHuAI3KbQpHW2Q++RdXfm5JzD5WdJZUU+Zn8t8UUn85BH4RxZLeE0qJikgSsKoYVBc6YhiMjhPgkR95ReimY4Z0xCJdRo1gjexOFeODZMpQF6Yxnoic7IrdgsFA3iePTbFnPp3IAM1fAThWhXJUn3QInUOTd5o1qmTmn6REbL15g/JQNl+dqUoPkhleeb2V3kjqp1okmO3wMZbPknR3S1LZNmlS72/iBQUm+n2b/RCn4PjmM2";
5
+
6
+ export const DEFAULT_THINKING_VERTEX_SIGNATURE = "CloBjz1rX5+yg1ILh/Ag+suum5k1f/9m/hI0XDQ33lsQIYnOHLn9KZwN0C7E4jgep5MzZvz5Se1Z1xxYrA1+Iz0Il4tabBhaDfMKNa5dGdEA3KnikfjfIpMlPaAKaQGPPWtf2hodPdBgguiZqDn+Qz2LwGEqHVJ16LVBUpeSx7UnYBLSwio8cyNy0jPijOh5QXKLTeHVdO2tKKcCCrtG2JCW3dOSrW2qA8eyAg40iQUnMNECjbcjkqB1+zrab7jX9ILwg7L9OgqYAQGPPWtfT4nzaPzSkXePAa920abYxPs3fg/RHDlg8PUVFLa+ko6qOjt7nXJTMxN0cpCwUCFX7eHHcMnA6vApyA/rXvJiAABkHZ3HilAktXRtxr/thHU0H8/4H5gT3kzoQcq9aMznrKomd3ct0mFi0ioSKnOEfoY1Mrfj00p/ZWm0tT7Wrjcm3BQXZ+T9Vrb94k+6CjtcEBrGCq8BAY89a1/vTMczqwB1NP3HCCuBdnds2vDXkj6XAYaXjsjmik8tGqwMKHz8R9RAWsx6SO6pkGEpXXpRzAaUx6c+aofsL/z1xOcN7ArCAa6uEeQKEgNngZuCP05p4+9P95epVmgOjFa4KfsPnyg+NKUkEFmpPSDrIRyMT+xERlclVcCI98/u7i8a9+vTbgzl8TRFYryClNH37K1ye5i6kqSGDUcMyiEasjke5BxbUh3i6wqMAgGPPWtfk+A+iY38QAldu117FEkTIkzbYOIt67lk9c6Ou3Y3Ct8TFHFw5QwGfSFc0YWjeTFHdm9UdV5jPK35p6VfhiRSva3w2+JLIHb4jvv5HutZPOJ3yQTt/+hUDj80oMNMbwnxNZvCEdzKS+D9vwmTACAm5H0ZetBSH2gPJXnhhuQo9AegS3wIWVR2a5k643Vx9r4u4pOvij4476lxKswIHvqsjL4jnTzRCvd44G6dn7vD0ENGb1K/i+dMRQMcOBaOxPN0ynk9bKxXWRDbZ+Rhakfr+y74z+6eYCdRPVqO9I7s+riilFuRIfaQ+U6/vuVKGIWEVKCfZZi0z6H5Xgz1xmse0u0AsittDlIKxwEBjz1rX783A0vehvUeabRia+/pX46IsN5efTAxFEBUeUce3jLuXIghkMV2b8KNhUs2G0aZldDDewRQbkluQabBMDT82N5I7reJP0VZgLIKccCL5DoGv1J7YWM2npLMIgZ6aP8aSlT3PFFJ0IXbUZUrzduczmIm6nzAJf9zxmq1aIFYw8YrgW8RjUdy0UvUmRoBEShSGUrvsyaRTl7J//KJW5utIPunFMu53GPWLidCFHzM1QA3Cj1+4zv5UXajP/V92RQayWbzCvYBAY89a1+yzoVSWukUGH7kX71Tg9dx7HA7OyKYwnYaqekG98zJfcUM/3KoiiiotW5t4xYu//ksEl36bSWvUHsRnxGByg+3WYdnZqKg0AtdRB/EXbI5PsjvS5ko96bkjSuFkY3TjHGwAM2B94K6/t6OTE/NBbxCsY9sT4d+1sbFv/iyfmfCnfvJaSzGmC9CDWKy4iqQ/vBNWps9j1JXk0p5uPAYC2BaMkxl5xoTVZqI3zAuRtQF5JLmPPy+PdqOgFxMKcLGNhwp7dbhIFLF68vCYQ9CL0NnK2d3CFk1UFVYxsi1TsolR1xahe/Rxt5HZDz/z65nevrQ";
7
+
8
+ export const DEFAULT_THINKING_GEMINI_CLI_SIGNATURE = "CiQBjz1rX/AlslZWMe5RgBt4Tv9j4+YNZTTez+JH2/+5oAlICygKXgGPPWtf7/Sux9eLYap/bmYAdPqFThLXj+l7o0DLu/hdgU98MA9ZrlRDNHXx+T0tuY8AcnjPZbiDyOq2bE11Fjhsk6p5axqayaapC/Pt9GczcgIQf1z15WTxCeKWAPYKYQGPPWtfDYj0nlNFNoTlU39RC91Z16xFKJ2MLEmkm+NvimsoOJ6be3g2BssNPtJ/9BKDXRA5cVs17tBeeW72lH8TMB5999udtxHM2SiUsnWsrHlfVuGSCpNQQ+5REw8HNvEKkgEBjz1rXzBNWrqZGbjun55K+vgYPBhJO2qZ67uRWXUA5/qcU12U/mbi5XoA3swoxYE8LEXfZvFFC9WG/W28QNCA0Qd4Trk/WkWiAwZmB8a84Fs14rkv3wqyxwFavPkJorqurAfd2XzGiFy0sB0ITCOPYi1HzDGV5WfXk6b9k+jT66/RuzGa8EcSOWo/QtC3Bkhgowo4AY89a1/f/tw8A02zjIoK7JVDAbf8W4UfmbApJJhwXIiGtu1M0JItObx7g2reYqT+HHL2Q/R4VDc=";
9
+
10
+
11
+ export const DEFAULT_THINKING_TEXT = "...";
12
+
open-sse/config/errorConfig.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // OpenAI-compatible error types mapping (client-facing)
2
+ export const ERROR_TYPES = {
3
+ 400: { type: "invalid_request_error", code: "bad_request" },
4
+ 401: { type: "authentication_error", code: "invalid_api_key" },
5
+ 402: { type: "billing_error", code: "payment_required" },
6
+ 403: { type: "permission_error", code: "insufficient_quota" },
7
+ 404: { type: "invalid_request_error", code: "model_not_found" },
8
+ 406: { type: "invalid_request_error", code: "model_not_supported" },
9
+ 429: { type: "rate_limit_error", code: "rate_limit_exceeded" },
10
+ 500: { type: "server_error", code: "internal_server_error" },
11
+ 502: { type: "server_error", code: "bad_gateway" },
12
+ 503: { type: "server_error", code: "service_unavailable" },
13
+ 504: { type: "server_error", code: "gateway_timeout" }
14
+ };
15
+
16
+ // Default error messages per status code (client-facing)
17
+ export const DEFAULT_ERROR_MESSAGES = {
18
+ 400: "Bad request",
19
+ 401: "Invalid API key provided",
20
+ 402: "Payment required",
21
+ 403: "You exceeded your current quota",
22
+ 404: "Model not found",
23
+ 406: "Model not supported",
24
+ 429: "Rate limit exceeded",
25
+ 500: "Internal server error",
26
+ 502: "Bad gateway - upstream provider error",
27
+ 503: "Service temporarily unavailable",
28
+ 504: "Gateway timeout"
29
+ };
30
+
31
+ // Exponential backoff config for rate limits
32
+ export const BACKOFF_CONFIG = {
33
+ base: 2000,
34
+ max: 5 * 60 * 1000,
35
+ maxLevel: 15
36
+ };
37
+
38
+ // Default cooldown for transient/unknown errors
39
+ export const TRANSIENT_COOLDOWN_MS = 30 * 1000;
40
+
41
+ // Hard cap for provider-reported rate limit cooldown (e.g. codex resets_at can be 5-6h)
42
+ export const MAX_RATE_LIMIT_COOLDOWN_MS = 30 * 60 * 1000;
43
+
44
+ // Cooldown durations (ms)
45
+ const COOLDOWN = {
46
+ long: 2 * 60 * 1000,
47
+ short: 5 * 1000,
48
+ };
49
+
50
+ /**
51
+ * Unified error classification rules.
52
+ * Checked top-to-bottom: text rules first (by order), then status rules.
53
+ * Each rule: { text?, status?, cooldownMs?, backoff? }
54
+ * - text: substring match (case-insensitive) on error message
55
+ * - status: HTTP status code match
56
+ * - cooldownMs: fixed cooldown duration
57
+ * - backoff: true = use exponential backoff (rate limit)
58
+ */
59
+ export const ERROR_RULES = [
60
+ // --- Text-based rules (checked first, order = priority) ---
61
+ { text: "no credentials", cooldownMs: COOLDOWN.long },
62
+ { text: "request not allowed", cooldownMs: COOLDOWN.short },
63
+ { text: "improperly formed request", cooldownMs: COOLDOWN.long },
64
+ { text: "rate limit", backoff: true },
65
+ { text: "too many requests", backoff: true },
66
+ { text: "quota exceeded", backoff: true },
67
+ { text: "capacity", backoff: true },
68
+ { text: "overloaded", backoff: true },
69
+
70
+ // --- Status-based rules (fallback when text doesn't match) ---
71
+ { status: 401, cooldownMs: COOLDOWN.long },
72
+ { status: 402, cooldownMs: COOLDOWN.long },
73
+ { status: 403, cooldownMs: COOLDOWN.long },
74
+ { status: 404, cooldownMs: COOLDOWN.long },
75
+ { status: 429, backoff: true },
76
+ ];
77
+
78
+ // Backward compat: COOLDOWN_MS object (used by index.js re-export)
79
+ export const COOLDOWN_MS = {
80
+ unauthorized: COOLDOWN.long,
81
+ paymentRequired: COOLDOWN.long,
82
+ notFound: COOLDOWN.long,
83
+ transient: TRANSIENT_COOLDOWN_MS,
84
+ requestNotAllowed: COOLDOWN.short,
85
+ };
open-sse/config/googleTtsLanguages.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const GOOGLE_TTS_LANGUAGES = [
2
+ { id: "af", name: "Afrikaans", type: "tts" },
3
+ { id: "ar", name: "Arabic", type: "tts" },
4
+ { id: "bg", name: "Bulgarian", type: "tts" },
5
+ { id: "bn", name: "Bengali", type: "tts" },
6
+ { id: "bs", name: "Bosnian", type: "tts" },
7
+ { id: "ca", name: "Catalan", type: "tts" },
8
+ { id: "cs", name: "Czech", type: "tts" },
9
+ { id: "cy", name: "Welsh", type: "tts" },
10
+ { id: "da", name: "Danish", type: "tts" },
11
+ { id: "de", name: "German", type: "tts" },
12
+ { id: "el", name: "Greek", type: "tts" },
13
+ { id: "en", name: "English", type: "tts" },
14
+ { id: "eo", name: "Esperanto", type: "tts" },
15
+ { id: "es", name: "Spanish", type: "tts" },
16
+ { id: "et", name: "Estonian", type: "tts" },
17
+ { id: "fi", name: "Finnish", type: "tts" },
18
+ { id: "fr", name: "French", type: "tts" },
19
+ { id: "gu", name: "Gujarati", type: "tts" },
20
+ { id: "hi", name: "Hindi", type: "tts" },
21
+ { id: "hr", name: "Croatian", type: "tts" },
22
+ { id: "hu", name: "Hungarian", type: "tts" },
23
+ { id: "hy", name: "Armenian", type: "tts" },
24
+ { id: "id", name: "Indonesian", type: "tts" },
25
+ { id: "is", name: "Icelandic", type: "tts" },
26
+ { id: "it", name: "Italian", type: "tts" },
27
+ { id: "ja", name: "Japanese", type: "tts" },
28
+ { id: "jw", name: "Javanese", type: "tts" },
29
+ { id: "km", name: "Khmer", type: "tts" },
30
+ { id: "kn", name: "Kannada", type: "tts" },
31
+ { id: "ko", name: "Korean", type: "tts" },
32
+ { id: "la", name: "Latin", type: "tts" },
33
+ { id: "lv", name: "Latvian", type: "tts" },
34
+ { id: "mk", name: "Macedonian", type: "tts" },
35
+ { id: "ml", name: "Malayalam", type: "tts" },
36
+ { id: "mr", name: "Marathi", type: "tts" },
37
+ { id: "my", name: "Myanmar (Burmese)", type: "tts" },
38
+ { id: "ne", name: "Nepali", type: "tts" },
39
+ { id: "nl", name: "Dutch", type: "tts" },
40
+ { id: "no", name: "Norwegian", type: "tts" },
41
+ { id: "pl", name: "Polish", type: "tts" },
42
+ { id: "pt", name: "Portuguese", type: "tts" },
43
+ { id: "ro", name: "Romanian", type: "tts" },
44
+ { id: "ru", name: "Russian", type: "tts" },
45
+ { id: "si", name: "Sinhala", type: "tts" },
46
+ { id: "sk", name: "Slovak", type: "tts" },
47
+ { id: "sq", name: "Albanian", type: "tts" },
48
+ { id: "sr", name: "Serbian", type: "tts" },
49
+ { id: "su", name: "Sundanese", type: "tts" },
50
+ { id: "sv", name: "Swedish", type: "tts" },
51
+ { id: "sw", name: "Swahili", type: "tts" },
52
+ { id: "ta", name: "Tamil", type: "tts" },
53
+ { id: "te", name: "Telugu", type: "tts" },
54
+ { id: "th", name: "Thai", type: "tts" },
55
+ { id: "tl", name: "Filipino", type: "tts" },
56
+ { id: "tr", name: "Turkish", type: "tts" },
57
+ { id: "uk", name: "Ukrainian", type: "tts" },
58
+ { id: "ur", name: "Urdu", type: "tts" },
59
+ { id: "vi", name: "Vietnamese", type: "tts" },
60
+ { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" },
61
+ { id: "zh-TW", name: "Chinese (Traditional)", type: "tts" },
62
+ ];
open-sse/config/kiroConstants.js ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Kiro-specific constants and helpers.
3
+ *
4
+ * Mirrors the behaviour of `internal/translator/kiro/common/constants.go` and
5
+ * `internal/translator/kiro/claude/kiro_claude_request.go` from the
6
+ * CLIProxyAPIPlus reference implementation, scoped down to what 9router needs:
7
+ *
8
+ * - `-agentic` model suffix detection + chunked-write system prompt
9
+ * - reasoning / thinking trigger detection (Anthropic-Beta header,
10
+ * Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag)
11
+ * - the `<thinking_mode>enabled</thinking_mode>` system-prompt injection
12
+ * that turns Kiro reasoning on
13
+ *
14
+ * Kiro upstream does not advertise `-agentic` model IDs; they are a 9router
15
+ * fiction. The suffix is stripped before the request leaves this process.
16
+ */
17
+
18
+ import { extractThinking } from "../translator/concerns/thinkingUnified.js";
19
+ import { effortToBudget } from "../translator/concerns/thinking.js";
20
+
21
+ export const KIRO_AGENTIC_SUFFIX = "-agentic";
22
+ export const KIRO_THINKING_SUFFIX = "-thinking";
23
+
24
+ // Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
25
+ // Used when an account cannot resolve its own profileArn. Builder ID and social
26
+ // (Google/GitHub) sign-ins map to different shared profiles.
27
+ export const KIRO_DEFAULT_PROFILE_ARNS = {
28
+ "builder-id": "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX",
29
+ social: "arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK",
30
+ };
31
+
32
+ // Back-compat single default (Builder ID).
33
+ export const KIRO_DEFAULT_PROFILE_ARN = KIRO_DEFAULT_PROFILE_ARNS["builder-id"];
34
+
35
+ /** Resolve the shared default profileArn for a given auth method. */
36
+ export function resolveDefaultProfileArn(authMethod) {
37
+ const social = authMethod === "google" || authMethod === "github";
38
+ return social ? KIRO_DEFAULT_PROFILE_ARNS.social : KIRO_DEFAULT_PROFILE_ARNS["builder-id"];
39
+ }
40
+
41
+ export const KIRO_THINKING_BUDGET_DEFAULT = 16000;
42
+
43
+ export const KIRO_AGENTIC_SYSTEM_PROMPT = `
44
+ # CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
45
+
46
+ You MUST follow these rules for ALL file operations. Violation causes server timeouts and task failure.
47
+
48
+ ## ABSOLUTE LIMITS
49
+ - **MAXIMUM 350 LINES** per single write/edit operation - NO EXCEPTIONS
50
+ - **RECOMMENDED 300 LINES** or less for optimal performance
51
+ - **NEVER** write entire files in one operation if >300 lines
52
+
53
+ ## MANDATORY CHUNKED WRITE STRATEGY
54
+
55
+ ### For NEW FILES (>300 lines total):
56
+ 1. FIRST: Write initial chunk (first 250-300 lines) using write_to_file/fsWrite
57
+ 2. THEN: Append remaining content in 250-300 line chunks using file append operations
58
+ 3. REPEAT: Continue appending until complete
59
+
60
+ ### For EDITING EXISTING FILES:
61
+ 1. Use surgical edits (apply_diff/targeted edits) - change ONLY what's needed
62
+ 2. NEVER rewrite entire files - use incremental modifications
63
+ 3. Split large refactors into multiple small, focused edits
64
+
65
+ ### For LARGE CODE GENERATION:
66
+ 1. Generate in logical sections (imports, types, functions separately)
67
+ 2. Write each section as a separate operation
68
+ 3. Use append operations for subsequent sections
69
+
70
+ ## EXAMPLES OF CORRECT BEHAVIOR
71
+
72
+ CORRECT: Writing a 600-line file
73
+ - Operation 1: Write lines 1-300 (initial file creation)
74
+ - Operation 2: Append lines 301-600
75
+
76
+ CORRECT: Editing multiple functions
77
+ - Operation 1: Edit function A
78
+ - Operation 2: Edit function B
79
+ - Operation 3: Edit function C
80
+
81
+ WRONG: Writing 500 lines in single operation -> TIMEOUT
82
+ WRONG: Rewriting entire file to change 5 lines -> TIMEOUT
83
+ WRONG: Generating massive code blocks without chunking -> TIMEOUT
84
+
85
+ ## WHY THIS MATTERS
86
+ - Server has 2-3 minute timeout for operations
87
+ - Large writes exceed timeout and FAIL completely
88
+ - Chunked writes are FASTER and more RELIABLE
89
+ - Failed writes waste time and require retry
90
+
91
+ REMEMBER: When in doubt, write LESS per operation. Multiple small operations > one large operation.
92
+ `.trim();
93
+
94
+ /**
95
+ * Resolve the Kiro thinking budget requested by a client.
96
+ *
97
+ * Reuses the shared thinkingUnified parser (extractThinking) so every client
98
+ * shape (Claude output_config.effort / thinking.budget_tokens, OpenAI
99
+ * reasoning_effort / reasoning.effort, Gemini, Qwen) maps consistently. Explicit
100
+ * `none`/`off`/disabled wins and returns null (no prefix injected).
101
+ * buildThinkingSystemPrefix performs Kiro's final 1..32000 clamp.
102
+ *
103
+ * @param {object} body OpenAI/Claude-shaped request body
104
+ * @param {object} [headers] Original inbound HTTP headers (case-insensitive)
105
+ * @param {string} [model] Model id the caller asked for
106
+ * @returns {number|null} budget to inject, or null when thinking is disabled
107
+ */
108
+ export function resolveKiroThinkingBudget(body, headers, model) {
109
+ const cfg = extractThinking(body);
110
+ if (cfg) {
111
+ if (cfg.mode === "none") return null;
112
+ if (cfg.mode === "budget") return cfg.budget;
113
+ if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT;
114
+ return KIRO_THINKING_BUDGET_DEFAULT;
115
+ }
116
+
117
+ if (headers) {
118
+ const beta = pickHeader(headers, "anthropic-beta");
119
+ if (typeof beta === "string" && beta.toLowerCase().includes("interleaved-thinking")) {
120
+ return KIRO_THINKING_BUDGET_DEFAULT;
121
+ }
122
+ }
123
+
124
+ if (containsThinkingModeTag(body)) return KIRO_THINKING_BUDGET_DEFAULT;
125
+
126
+ if (typeof model === "string" && model) {
127
+ const m = model.toLowerCase();
128
+ if (m.includes("thinking") || m.includes("-reason")) return KIRO_THINKING_BUDGET_DEFAULT;
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Detect whether an inbound request is asking for reasoning / thinking output.
136
+ * Thin wrapper over resolveKiroThinkingBudget (single source of truth).
137
+ *
138
+ * @param {object} body OpenAI-shaped request body (post-translation)
139
+ * @param {object} [headers] Original inbound HTTP headers (case-insensitive)
140
+ * @param {string} [model] Model id the caller asked for (post-strip ok)
141
+ * @returns {boolean}
142
+ */
143
+ export function isThinkingEnabled(body, headers, model) {
144
+ return resolveKiroThinkingBudget(body, headers, model) !== null;
145
+ }
146
+
147
+ /**
148
+ * Detect whether a model id refers to a 9router synthetic agentic variant.
149
+ * Agentic variants share the same upstream model as the base; the only
150
+ * difference is the chunked-write system prompt this module injects.
151
+ *
152
+ * @param {string} model
153
+ * @returns {boolean}
154
+ */
155
+ export function isAgenticModel(model) {
156
+ return typeof model === "string" && model.endsWith(KIRO_AGENTIC_SUFFIX);
157
+ }
158
+
159
+ /**
160
+ * Strip the `-agentic` suffix from a model id, leaving the upstream-real id.
161
+ *
162
+ * @param {string} model
163
+ * @returns {string}
164
+ */
165
+ export function stripAgenticSuffix(model) {
166
+ if (!isAgenticModel(model)) return model;
167
+ return model.slice(0, -KIRO_AGENTIC_SUFFIX.length);
168
+ }
169
+
170
+ /**
171
+ * Detect whether a model id is a 9router synthetic thinking variant
172
+ * (e.g. `claude-sonnet-4.5-thinking`). Same upstream model as the base; the
173
+ * only difference is `<thinking_mode>enabled</thinking_mode>` injection.
174
+ *
175
+ * Note: real Kiro thinking-capable variants exist (e.g. `kimi-k2-thinking` in
176
+ * other providers), but for the `kr/` namespace there is no `-thinking`
177
+ * model on Kiro upstream. Treat the suffix as a synthetic alias.
178
+ *
179
+ * @param {string} model Model id with `-agentic` already stripped
180
+ * @returns {boolean}
181
+ */
182
+ export function isThinkingModel(model) {
183
+ return typeof model === "string" && model.endsWith(KIRO_THINKING_SUFFIX);
184
+ }
185
+
186
+ /**
187
+ * Strip the `-thinking` suffix from a model id.
188
+ *
189
+ * @param {string} model
190
+ * @returns {string}
191
+ */
192
+ export function stripThinkingSuffix(model) {
193
+ if (!isThinkingModel(model)) return model;
194
+ return model.slice(0, -KIRO_THINKING_SUFFIX.length);
195
+ }
196
+
197
+ /**
198
+ * Resolve a 9router model id to the real upstream Kiro model id, plus flags
199
+ * describing which behaviours the suffixes implied.
200
+ *
201
+ * resolveKiroModel("claude-sonnet-4.5-thinking-agentic")
202
+ * => { upstream: "claude-sonnet-4.5", agentic: true, thinking: true }
203
+ * resolveKiroModel("claude-sonnet-4.5-thinking")
204
+ * => { upstream: "claude-sonnet-4.5", agentic: false, thinking: true }
205
+ * resolveKiroModel("claude-sonnet-4.5-agentic")
206
+ * => { upstream: "claude-sonnet-4.5", agentic: true, thinking: false }
207
+ * resolveKiroModel("claude-sonnet-4.5")
208
+ * => { upstream: "claude-sonnet-4.5", agentic: false, thinking: false }
209
+ *
210
+ * @param {string} model
211
+ * @returns {{ upstream: string, agentic: boolean, thinking: boolean }}
212
+ */
213
+ export function resolveKiroModel(model) {
214
+ let upstream = model;
215
+ let agentic = false;
216
+ let thinking = false;
217
+ if (isAgenticModel(upstream)) {
218
+ agentic = true;
219
+ upstream = stripAgenticSuffix(upstream);
220
+ }
221
+ if (isThinkingModel(upstream)) {
222
+ thinking = true;
223
+ upstream = stripThinkingSuffix(upstream);
224
+ }
225
+ return { upstream, agentic, thinking };
226
+ }
227
+
228
+ /**
229
+ * Build the magic system-prompt prefix that turns Kiro reasoning on.
230
+ * Same shape as CLIProxyAPIPlus.
231
+ *
232
+ * @param {number} [budget=KIRO_THINKING_BUDGET_DEFAULT]
233
+ */
234
+ export function buildThinkingSystemPrefix(budget = KIRO_THINKING_BUDGET_DEFAULT) {
235
+ const safeBudget = Math.max(1, Math.min(32000, Number(budget) || KIRO_THINKING_BUDGET_DEFAULT));
236
+ return `<thinking_mode>enabled</thinking_mode>\n<max_thinking_length>${safeBudget}</max_thinking_length>`;
237
+ }
238
+
239
+ function pickHeader(headers, name) {
240
+ if (!headers) return undefined;
241
+ if (typeof headers.get === "function") {
242
+ return headers.get(name);
243
+ }
244
+ const lower = name.toLowerCase();
245
+ for (const key of Object.keys(headers)) {
246
+ if (key.toLowerCase() === lower) {
247
+ return headers[key];
248
+ }
249
+ }
250
+ return undefined;
251
+ }
252
+
253
+ function containsThinkingModeTag(body) {
254
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
255
+ for (const msg of messages) {
256
+ if (!msg) continue;
257
+ if (msg.role !== "system" && msg.role !== "user") continue;
258
+ const content = msg.content;
259
+ if (typeof content === "string") {
260
+ if (containsTagInText(content)) return true;
261
+ } else if (Array.isArray(content)) {
262
+ for (const part of content) {
263
+ const text = part?.text;
264
+ if (typeof text === "string" && containsTagInText(text)) return true;
265
+ }
266
+ }
267
+ }
268
+ if (typeof body?.system === "string" && containsTagInText(body.system)) return true;
269
+ return false;
270
+ }
271
+
272
+ function containsTagInText(text) {
273
+ if (!text) return false;
274
+ if (!text.includes("<thinking_mode>")) return false;
275
+ return text.includes("<thinking_mode>enabled</thinking_mode>")
276
+ || text.includes("<thinking_mode>interleaved</thinking_mode>");
277
+ }
open-sse/config/mediaConfig.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Central config for remote-media fetching security limits.
2
+
3
+ // Max bytes accepted from a remote image fetch (reject larger to prevent memory DoS).
4
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB
5
+
6
+ // Fetch timeout for remote media.
7
+ export const FETCH_TIMEOUT_MS = 10000;
8
+
9
+ // Magic-byte signatures -> mime. Each entry: { sig:[bytes], offset, mime }.
10
+ // offset>0 for containers where the signature is not at byte 0 (e.g. webp).
11
+ export const IMAGE_SIGNATURES = [
12
+ { sig: [0x89, 0x50, 0x4e, 0x47], offset: 0, mime: "image/png" },
13
+ { sig: [0xff, 0xd8, 0xff], offset: 0, mime: "image/jpeg" },
14
+ { sig: [0x47, 0x49, 0x46, 0x38], offset: 0, mime: "image/gif" },
15
+ { sig: [0x52, 0x49, 0x46, 0x46], offset: 0, mime: "image/webp", verifyWebp: true },
16
+ { sig: [0x42, 0x4d], offset: 0, mime: "image/bmp" },
17
+ ];
18
+
19
+ // Hostnames/IPs that must never be fetched (SSRF guard for loopback + cloud metadata).
20
+ export const BLOCKED_HOSTS = new Set([
21
+ "localhost",
22
+ "127.0.0.1",
23
+ "0.0.0.0",
24
+ "::1",
25
+ "169.254.169.254", // AWS/GCP/Azure IMDS
26
+ "metadata.google.internal",
27
+ ]);
open-sse/config/models.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Model metadata registry
2
+ // Only define models that differ from DEFAULT_MODEL_INFO
3
+ // Custom entries are merged over default
4
+ const DEFAULT_MODEL_INFO = {
5
+ type: ["chat"],
6
+ contextWindow: 200000,
7
+ };
8
+
9
+ export const MODEL_INFO = {};
10
+
11
+ export function getModelInfo(modelId) {
12
+ return { ...DEFAULT_MODEL_INFO, ...MODEL_INFO[modelId] };
13
+ }
open-sse/config/ollamaModels.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ollamaModels = {
2
+ models: [
3
+ {
4
+ name: "llama3.2",
5
+ modified_at: "2025-12-26T00:00:00Z",
6
+ size: 2000000000,
7
+ digest: "abc123def456",
8
+ details: { format: "gguf", family: "llama", parameter_size: "3B", quantization_level: "Q4_K_M" }
9
+ },
10
+ {
11
+ name: "qwen2.5",
12
+ modified_at: "2025-12-26T00:00:00Z",
13
+ size: 4000000000,
14
+ digest: "def456abc123",
15
+ details: { format: "gguf", family: "qwen", parameter_size: "7B", quantization_level: "Q4_K_M" }
16
+ }
17
+ ]
18
+ };
19
+
open-sse/config/providerModels.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PROVIDERS } from "./providers.js";
2
+ import REGISTRY from "../providers/registry/index.js";
3
+ // PROVIDER_MODELS now built from providers/registry (transport + models co-located)
4
+ import { PROVIDER_MODELS } from "../providers/index.js";
5
+ import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js";
6
+ import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
7
+
8
+ export { PROVIDER_MODELS };
9
+
10
+
11
+ // Helper functions
12
+ export function getProviderModels(aliasOrId) {
13
+ return PROVIDER_MODELS[aliasOrId] || [];
14
+ }
15
+
16
+ export function getDefaultModel(aliasOrId) {
17
+ const models = PROVIDER_MODELS[aliasOrId];
18
+ return models?.[0]?.id || null;
19
+ }
20
+
21
+ export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) {
22
+ if (passthroughProviders.has(aliasOrId)) return true;
23
+ const models = PROVIDER_MODELS[aliasOrId];
24
+ if (!models) return false;
25
+ return models.some(m => m.id === modelId);
26
+ }
27
+
28
+ export function findModelName(aliasOrId, modelId) {
29
+ const models = PROVIDER_MODELS[aliasOrId];
30
+ if (!models) return modelId;
31
+ const found = models.find(m => m.id === modelId);
32
+ return found?.name || modelId;
33
+ }
34
+
35
+ export function getModelTargetFormat(aliasOrId, modelId) {
36
+ const models = PROVIDER_MODELS[aliasOrId];
37
+ if (!models) return null;
38
+ return modelTargetFormat(models.find(m => m.id === modelId));
39
+ }
40
+
41
+ export function getModelType(aliasOrId, modelId) {
42
+ const models = PROVIDER_MODELS[aliasOrId];
43
+ if (!models) return null;
44
+ const found = models.find(m => m.id === modelId);
45
+ return found?.kind || found?.type || null;
46
+ }
47
+
48
+ export function getModelUpstreamId(aliasOrId, modelId) {
49
+ const models = PROVIDER_MODELS[aliasOrId];
50
+ const found = models?.find(m => m.id === modelId);
51
+ if (found?.upstreamModelId) return found.upstreamModelId;
52
+ if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) {
53
+ return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length);
54
+ }
55
+ return modelId;
56
+ }
57
+
58
+ export function getModelQuotaFamily(aliasOrId, modelId) {
59
+ const models = PROVIDER_MODELS[aliasOrId];
60
+ return modelQuotaFamily(models?.find(m => m.id === modelId));
61
+ }
62
+
63
+ // OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id.
64
+ // vertex/vertex-partner keep alias=id (kept via the `|| id` fallback in consumers).
65
+ export const OAUTH_ALIASES = Object.fromEntries(
66
+ REGISTRY.filter(r => r.alias && r.alias !== r.id).map(r => [r.id, r.alias])
67
+ );
68
+
69
+ // Derived from PROVIDERS — no need to maintain manually
70
+ export const PROVIDER_ID_TO_ALIAS = Object.fromEntries(
71
+ Object.keys(PROVIDERS).map(id => [id, OAUTH_ALIASES[id] || id])
72
+ );
73
+
74
+ export function getModelsByProviderId(providerId) {
75
+ const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
76
+ return PROVIDER_MODELS[alias] || [];
77
+ }
78
+
79
+ // Get strip list for a model entry (explicit opt-in only)
80
+ // Returns array of content types to strip, e.g. ["image", "audio"]
81
+ export function getModelStrip(alias, modelId) {
82
+ return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId));
83
+ }
open-sse/config/providers.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Barrel: PROVIDERS now built from providers/registry (transport co-located with models)
2
+ import { PROVIDERS } from "../providers/index.js";
3
+ export { PROVIDERS, PROVIDER_OAUTH } from "../providers/index.js";
4
+
5
+ export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";
6
+
7
+ export function resolveOllamaLocalHost(credentials) {
8
+ const raw = credentials?.providerSpecificData?.baseUrl?.trim();
9
+ return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, "");
10
+ }
11
+
12
+ // Region URLs single-source from registry xiaomi-tokenplan.transport
13
+ export const XIAOMI_TOKENPLAN_REGIONS = PROVIDERS["xiaomi-tokenplan"]?.regions || {};
14
+ export const XIAOMI_TOKENPLAN_DEFAULT_REGION = PROVIDERS["xiaomi-tokenplan"]?.defaultRegion;
15
+
16
+ export function resolveXiaomiTokenplanBaseUrl(credentials) {
17
+ const region = credentials?.providerSpecificData?.region;
18
+ return XIAOMI_TOKENPLAN_REGIONS[region] || XIAOMI_TOKENPLAN_REGIONS[XIAOMI_TOKENPLAN_DEFAULT_REGION];
19
+ }
open-sse/config/runtimeConfig.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // HTTP status codes
2
+ export const HTTP_STATUS = {
3
+ BAD_REQUEST: 400,
4
+ UNAUTHORIZED: 401,
5
+ PAYMENT_REQUIRED: 402,
6
+ FORBIDDEN: 403,
7
+ NOT_FOUND: 404,
8
+ NOT_ACCEPTABLE: 406,
9
+ REQUEST_TIMEOUT: 408,
10
+ RATE_LIMITED: 429,
11
+ SERVER_ERROR: 500,
12
+ BAD_GATEWAY: 502,
13
+ SERVICE_UNAVAILABLE: 503,
14
+ GATEWAY_TIMEOUT: 504
15
+ };
16
+
17
+ // Re-export error config (backward compat)
18
+ export { ERROR_TYPES, DEFAULT_ERROR_MESSAGES, BACKOFF_CONFIG, COOLDOWN_MS } from "./errorConfig.js";
19
+
20
+ // Cache TTLs (seconds)
21
+ export const CACHE_TTL = {
22
+ userInfo: 300, // 5 minutes
23
+ modelAlias: 3600 // 1 hour
24
+ };
25
+
26
+ // Memory management config
27
+ export const MEMORY_CONFIG = {
28
+ sessionTtlMs: 2 * 60 * 60 * 1000,
29
+ sessionCleanupIntervalMs: 30 * 60 * 1000,
30
+ dnsCacheTtlMs: 5 * 60 * 1000,
31
+ proxyDispatchersMaxSize: 20,
32
+ };
33
+
34
+ // Parse a positive integer env override, falling back to a default.
35
+ function envMs(name, def) {
36
+ const raw = process.env[name];
37
+ if (raw == null || raw === "") return def;
38
+ const n = parseInt(raw, 10);
39
+ return Number.isFinite(n) && n > 0 ? n : def;
40
+ }
41
+
42
+ // Inter-chunk stall timeout (once tokens are flowing). Generous headroom so
43
+ // slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS.
44
+ export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000);
45
+
46
+ // Time-to-first-token timeout (prompt prefill). Env: STREAM_FIRST_CHUNK_TIMEOUT_MS.
47
+ export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_MS", 200 * 1000);
48
+
49
+ // Fetch connect timeout: abort if upstream doesn't return response headers within this duration
50
+ export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
51
+
52
+ // Default token limits
53
+ export const DEFAULT_MAX_TOKENS = 64000;
54
+ export const DEFAULT_MIN_TOKENS = 32000;
55
+
56
+ // Retry config for 429 responses (legacy - kept for backward compatibility)
57
+ export const RETRY_CONFIG = {
58
+ maxAttempts: 2,
59
+ delayMs: 2000
60
+ };
61
+
62
+ // Default retry config by status code: { attempts, delayMs }
63
+ // Backward compat: if value is a number, treated as attempts with RETRY_CONFIG.delayMs
64
+ export const DEFAULT_RETRY_CONFIG = {
65
+ 429: { attempts: 0, delayMs: 0 },
66
+ 502: { attempts: 3, delayMs: 3000 },
67
+ 503: { attempts: 3, delayMs: 2000 },
68
+ 504: { attempts: 2, delayMs: 3000 }
69
+ };
70
+
71
+ // Normalize a retry entry to { attempts, delayMs }
72
+ export function resolveRetryEntry(entry) {
73
+ if (entry == null) return { attempts: 0, delayMs: RETRY_CONFIG.delayMs };
74
+ if (typeof entry === "number") return { attempts: entry, delayMs: RETRY_CONFIG.delayMs };
75
+ return {
76
+ attempts: entry.attempts || 0,
77
+ delayMs: entry.delayMs != null ? entry.delayMs : RETRY_CONFIG.delayMs
78
+ };
79
+ }
80
+
81
+ // Requests containing these texts will bypass provider
82
+ export const SKIP_PATTERNS = [
83
+ "Please write a 5-10 word title for the following conversation:"
84
+ ];