@1endpoint/coding-helper 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,123 +1,196 @@
1
1
  # @1endpoint/coding-helper
2
2
 
3
- Safe, isolated setup for **DeepSeek Harness** and **Claude Code CLI** using the single 1endpoint API base URL:
3
+ Connect **DeepSeek Harness** or **Claude Code** to 1endpoint with an interactive setup menu. Choose your language, select models, and manage your connection without editing configuration files by hand.
4
4
 
5
- ```text
6
- https://1endpoint.dev/api/v1
5
+ ## Before you start
6
+
7
+ - Install **Node.js 22.19 or newer**.
8
+ - Install [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) or [Claude Code](https://code.claude.com/docs/en/overview) separately. The helper configures these tools; it does not install them.
9
+ - Have a 1endpoint API key ready. Keep it private: do not paste it into command arguments, shared configuration files or a repository.
10
+
11
+ ## 1. Open the helper
12
+
13
+ Run this in your terminal:
14
+
15
+ ```bash
16
+ npx @1endpoint/coding-helper
7
17
  ```
8
18
 
9
- **Release status: not yet published to npm.** The following registry commands become available only after the maintainer publishes the package. Until then, run the local executable or a reviewed tarball. This helper is an independent integration, not an official DeepSeek or Anthropic product.
19
+ 1. Choose **English** or **Tiếng Việt**.
20
+ 2. Choose **Setup**.
21
+ 3. Select **DSH** or **Claude Code**.
22
+ 4. Paste your API key into the masked prompt, or select **Open Console** and press Enter to create a key first.
23
+ 5. Choose whether to save the key locally or use it for this session only.
24
+ 6. Select models from the 1endpoint catalog.
25
+ 7. Review the selection, then choose **Confirm**.
26
+
27
+ Use **↑ / ↓** to move and **Enter** to select. Press **Escape** to cancel. Confirmation is a selection, not a typed `y/n` answer.
28
+
29
+ ### Choosing models
30
+
31
+ For DSH, choose the model you want to use by default.
32
+
33
+ For Claude Code, choose a 1endpoint model for each tier: **Haiku**, **Sonnet** and **Opus**. Each tier can use a different model. Sonnet is used as the main/default selection. Select optional **Fable** mapping only if your installed Claude Code version supports that tier; otherwise keep the standard three-tier option.
34
+
35
+ If the catalog cannot be loaded, choose **Retry**, explicitly choose **Manual model ID**, or **Cancel**. The helper does not silently substitute a model.
36
+
37
+ ## 2. Provide your API key
10
38
 
11
- ## Requirements
39
+ Paste your key directly into the helper's **masked API-key prompt**. The key is not shown on screen or placed in command arguments.
12
40
 
13
- - Node.js **22.19 or newer** (tested locally on Node 24).
14
- - Install the desired coding tool separately from its official distribution. The helper does not download tools or run installation scripts.
15
- - An enabled public model ID from the [1endpoint model catalog](https://console.1endpoint.dev/models).
16
- - Set `ONEENDPOINT_API_KEY` in the launching process environment using your shell or secret manager. Never put the key in command arguments, a Git repository, or a shared configuration file.
41
+ Need a key? Select **Open Console** and press **Enter**. The helper opens [API keys](https://console.1endpoint.dev/api-keys), or the [Vietnamese API-key page](https://console.1endpoint.dev/vi/api-keys) when Vietnamese is selected. Create a key, copy it, return to the terminal and paste it into the prompt.
17
42
 
18
- ## Setup
43
+ Choose how to use the key:
19
44
 
20
- After publication:
45
+ - **Save locally:** remember it for the selected tool, so future launches do not require manual environment export. The saved file is plaintext, **not encrypted**, protected with private file permissions (0600 file, 0700 directory on POSIX systems). Other processes running as your user may still access it.
46
+ - **This session only:** keep it in memory for this helper process. After setup, select **Start a new session now** to launch with it. Reopening the helper later requires entering the key again.
47
+ - **Use an existing key:** reuse an available environment or saved key without displaying its value.
48
+
49
+ DSH and Claude Code store their keys separately. An explicit session key takes priority, followed by `ONEENDPOINT_API_KEY`, then the selected tool's saved key. The helper never puts keys in generated tool settings, model configuration, backups or logs.
50
+
51
+ ### Optional: use an environment variable
52
+
53
+ If you prefer a secret manager or shell environment instead of local storage, provide `ONEENDPOINT_API_KEY`. For **Bash**, this hidden prompt keeps the key out of the command itself:
21
54
 
22
55
  ```bash
23
- npx @1endpoint/coding-helper
24
- # First choose English / Tiếng Việt, then the action.
25
- # Colorful menu: use ↑/↓ and Enter; Escape cancels. No typed y/n.
26
- # Setup, reconfigure, remove, diagnose and run are available in the menu.
27
- # Fetches public 1endpoint text models for selection; Claude tiers map independently.
28
- # Or explicit, scriptable setup:
29
- npx @1endpoint/coding-helper setup --tool dsh --model your-model-id --dry-run
30
- npx @1endpoint/coding-helper setup --tool dsh --model your-model-id
31
- npx @1endpoint/coding-helper setup --tool claude --model your-model-id
56
+ read -r -s -p "1endpoint API key: " ONEENDPOINT_API_KEY
57
+ printf '\n'
58
+ export ONEENDPOINT_API_KEY
59
+ ```
60
+
61
+ For another shell, use its secure environment-variable mechanism or your secret manager. Do not type a real key into a command you intend to share. A key set in one terminal is not automatically available in another terminal or after restarting the shell.
62
+
63
+ ## 3. Check the connection setup
64
+
65
+ For DSH:
66
+
67
+ ```bash
68
+ npx @1endpoint/coding-helper doctor --tool dsh
69
+ ```
70
+
71
+ For Claude Code:
72
+
73
+ ```bash
74
+ npx @1endpoint/coding-helper doctor --tool claude
75
+ ```
76
+
77
+ Doctor checks your saved connection configuration, whether the tool is installed, and whether an environment or saved API key is available. It does not validate the key against the server or make an inference request.
78
+
79
+ ## 4. Start a new tool session
80
+
81
+ **After setup or reconfiguration, close the old tool session and start a new session through the helper. An already-open session does not pick up the new connection or model mapping.**
82
+
83
+ Start DSH:
84
+
85
+ ```bash
86
+ npx @1endpoint/coding-helper run --tool dsh
32
87
  ```
33
88
 
34
- Before publication, from the repository:
89
+ Start Claude Code:
35
90
 
36
91
  ```bash
37
- yarn install --immutable
38
- yarn test
39
- node src/cli.mjs setup --tool dsh --model your-model-id --dry-run
40
- node src/cli.mjs setup --tool dsh --model your-model-id
92
+ npx @1endpoint/coding-helper run --tool claude
41
93
  ```
42
94
 
43
- **After setup or reconfigure, restart the DSH/Claude Code session for changes to take effect. An already-open session does not update automatically. Start the new session through `coding-helper run`, not the original tool command, to use the isolated helper profile.**
95
+ You can also reopen the helper and select **Run**.
96
+
97
+ **Use the helper to launch the configured session.** Running `dsh` or `claude` directly uses the tool's ordinary configuration, not this helper connection. Tool requests may incur charges; the tool's normal permission and workspace-trust prompts remain active.
98
+
99
+ ### Important Claude Code behavior
100
+
101
+ The helper uses an isolated configuration. **Project/local settings, including their hooks and permissions, are not loaded in this mode.** Managed organization policy still applies, and the helper does not enable permission bypass. If your project requires those local restrictions or hooks, use your organization's approved configuration instead.
44
102
 
45
- The wizard begins with **English / Tiếng Việt** selection before configuration/reconfiguration/deletion. It fetches the public 1endpoint text model catalog for arrow-key choices. Claude's Haiku, Sonnet and Opus tiers can each map to a different public model; optional Fable mapping is available for versions supporting that tier. The helper does not infer vendor/model equivalence from names. If catalog loading fails, choose Retry, explicit manual ID entry, or Cancel.
103
+ ### Important DSH behavior
46
104
 
47
- Setup never asks for, reads, validates or stores your API key. It creates only helper-owned files under `~/.1endpoint/coding-helper`. Use an absolute `ONEENDPOINT_HELPER_HOME` to choose a different state directory. Dry-run makes no writes. Existing DSH and Claude user configuration is not edited or imported.
105
+ DSH runs with a separate home, so your ordinary sessions, plugins and provider settings are not imported or changed. Supported profiles are `web` (default), `headless`, `acp` and `sdk`; `sdk-minimal` is not supported.
48
106
 
49
- ## Diagnose and launch
107
+ ## Change models
50
108
 
51
- After setting the key in your process environment:
109
+ Open the helper again, select **Reconfigure**, choose the tool, and select new models:
52
110
 
53
111
  ```bash
54
- coding-helper doctor --tool dsh
55
- coding-helper run --tool dsh
56
- coding-helper run --tool dsh --profile headless -- 'Reply exactly OK'
57
- coding-helper doctor --tool claude
58
- coding-helper run --tool claude
59
- coding-helper run --tool claude -- -p 'Reply exactly OK'
112
+ npx @1endpoint/coding-helper
60
113
  ```
61
114
 
62
- For npx-only use, replace `coding-helper` with `npx @1endpoint/coding-helper`. For local use, replace it with `node src/cli.mjs`.
115
+ The previous connection configuration is backed up automatically. **Restart the tool session through the helper after confirming the change.**
116
+
117
+ For scripted use, preview and apply a DSH model change:
118
+
119
+ ```bash
120
+ npx @1endpoint/coding-helper reconfigure --tool dsh --model your-model-id --dry-run
121
+ npx @1endpoint/coding-helper reconfigure --tool dsh --model your-model-id
122
+ ```
63
123
 
64
- `doctor` verifies owned-file integrity, CLI availability and whether the key environment variable is present. It does **not** validate credentials, make network requests, call inference, execute an apiKeyHelper or read your original tool credentials. `run` is explicit consent to launch the selected tool; tool startup and inference can contact networks and incur charges. Permission/trust prompts remain tool-owned. Authentication, provider health and real inference are separate from local configuration tests.
124
+ Replace `your-model-id` with the exact public model ID you want to use. Replace `dsh` with `claude` for Claude Code. To set separate Claude tiers explicitly:
65
125
 
66
- ### DeepSeek Harness
126
+ ```bash
127
+ npx @1endpoint/coding-helper reconfigure --tool claude \
128
+ --model your-main-model-id \
129
+ --haiku-model your-fast-model-id \
130
+ --sonnet-model your-balanced-model-id \
131
+ --opus-model your-strong-model-id
132
+ ```
67
133
 
68
- - Uses a separate `DSH_HOME` plus a dedicated `--patch` overlay.
69
- - Configures the native `llm-pi-ai` provider with `api: openai-completions`, `baseURL` and `apiKeyEnv: ONEENDPOINT_API_KEY`.
70
- - Configures the native `agent-default-model` selection. No secret interpolation, custom proxy or injected agent instruction is required.
71
- - Supported profiles: `web`, `headless`, `acp`, `sdk`. `sdk-minimal` lacks the required entries and is deliberately rejected.
72
- - The helper does not reuse current sessions, plugins, model presets or provider configurations. Existing sessions can retain their own model selection; start a new session in the isolated profile.
73
- - DSH owns request assembly, conversation state, streaming, tool loops and permissions. The helper only sets the connection.
74
- - Verified configuration contract: DSH `0.2.1-alpha.3`. This prerelease contract may change. A successful dump exit code alone is insufficient: the integration tests also inspect provider and selected-model rows. The shipped Web schema dump has a separate preset/tree-carrier failure; headless schema validation is the supported check.
134
+ An optional `--fable-model` sets Fable explicitly. Without it, Fable follows Opus. Explicit tier selections take priority over the `--model` fallback. Explicit model IDs in your own agent/skill definitions or managed policy are not rewritten by these default mappings.
75
135
 
76
- ### Claude Code CLI
136
+ ## Remove the connection
77
137
 
78
- - Uses a separate `CLAUDE_CONFIG_DIR`, a helper-owned `--settings` file and `--setting-sources user`.
79
- - **Project and local settings, including their hooks and permissions, are intentionally not loaded.** This is a tradeoff for deterministic routing, not transparent preservation of project behavior. Managed/organization policy still applies and can override the connection. Do not use this mode if those project restrictions are required; configure the tool manually with your administrator instead.
80
- - The raw process key becomes `ANTHROPIC_AUTH_TOKEN` in the child environment only. The CLI constructs the Bearer header. No key is written to settings or included in argv.
81
- - Maps main, Opus, Sonnet, Haiku, optional Fable and default subagent roles. The wizard offers distinct catalog selections per tier; scripted setup accepts `--haiku-model`, `--sonnet-model`, `--opus-model` and optional `--fable-model`. `--model` is the main/default fallback; explicit role selections take priority. Without a Fable mapping it follows the Opus selection. Explicit model IDs in agent/skill definitions or managed policy are outside that default mapping; no promise is made to rewrite every model choice.
82
- - Clears inherited conflicting Anthropic/cloud authentication and routing variables in the child only. It does not log out, modify the parent's environment, bypass permission prompts or execute existing key helpers.
83
- - Uses the same universal base URL above. Claude appends `/v1/messages`, producing `/api/v1/v1/messages`, a supported gateway alias. Do not change the base to a different per-client URL.
84
- - Claude owns request assembly, conversation state, streaming and tool execution. Version verification and fixture boundaries are recorded in the release checklist.
138
+ Stop the tool session first. Open the helper, select **Remove**, choose the tool and confirm the removal. You do not need to find or delete configuration files yourself.
85
139
 
86
- ## Update, backup and recovery
140
+ Alternatively, preview and confirm removal:
87
141
 
88
142
  ```bash
89
- coding-helper backup --tool dsh
90
- coding-helper reconfigure --tool dsh --model another-public-model --dry-run
91
- coding-helper reconfigure --tool dsh --model another-public-model
92
- coding-helper remove --tool dsh --dry-run
93
- coding-helper remove --tool dsh --yes
143
+ npx @1endpoint/coding-helper remove --tool dsh --dry-run
144
+ npx @1endpoint/coding-helper remove --tool dsh --yes
94
145
  ```
95
146
 
96
- `reconfigure` validates the unchanged helper manifest/configuration, automatically backs up the verified old connection configuration and changes the model. Legacy `setup --update` is also available. Backups contain only generated configuration, never keys. Identical setup is idempotent. Normal setup refuses unknown files, modified configuration, malformed manifests and symlink state paths. Writes use an exclusive setup lock, private file permissions and atomic replacement; failed configuration writes attempt rollback.
147
+ Use `--tool claude` for Claude Code. The menu asks whether to **keep or delete the saved API key**. For scripted removal, add `--remove-key` to delete it explicitly:
148
+
149
+ ```bash
150
+ npx @1endpoint/coding-helper remove --tool dsh --yes --remove-key
151
+ ```
97
152
 
98
- `remove` previews with `--dry-run` and requires explicit `--yes` to delete the selected tool's generated connection file and manifest. It preserves runtime homes, conversations, backups, unrelated files and all original tool settings. **Stop the corresponding tool before removing its connection configuration.** To return to normal tools, launch `dsh` or `claude` directly without the helper.
153
+ Without that choice, the saved key is retained. Removal deletes only the helper-generated connection configuration and, when selected, its saved key. **Your original tool settings, runtime conversations and configuration backups are preserved.** To return to your ordinary setup, launch `dsh` or `claude` directly.
99
154
 
100
- For corrupted or hand-modified helper configuration, use `coding-helper remove --tool dsh --force --yes` (or `claude`), then run setup again. Force recovery targets only the fixed helper-generated connection file and manifest, never paths read from a damaged manifest; it does not copy potentially secret-bearing tampered content into backups. Symlinks remain refused even in force mode. There is no need to find and delete configuration files manually. Runtime homes may contain tool-created conversations or credentials; they are intentionally retained, not silently wiped or backed up. Do not share or commit them. A stale setup lock still requires reviewing that no setup is active before retrying; do not defeat a live writer lock.
155
+ ### Recover from damaged connection configuration
101
156
 
102
- This is configuration isolation, **not an OS sandbox**. The coding tools still have their ordinary filesystem/network access and can create runtime data. No unattended install, background daemon, automatic paid smoke, telemetry or global config migration is included.
157
+ If the helper reports that its generated configuration has been modified or corrupted, choose **Force removal recovery** in the Remove menu, review the warning and confirm. Then run Setup again.
103
158
 
104
- ## Development and packaging
159
+ For scripted recovery:
105
160
 
106
161
  ```bash
107
- yarn install --immutable
108
- yarn check
109
- yarn test
110
- yarn pack --out ../coding-helper-0.1.0.tgz
162
+ npx @1endpoint/coding-helper remove --tool dsh --force --yes
111
163
  ```
112
164
 
113
- No production dependencies and no build step. The published entry is native ESM. Package contents are allowlisted. See the [publishing guide](docs/PUBLISHING.md) for step-by-step npm authentication, first publication, verification, updates and troubleshooting. See the [release checklist](docs/RELEASE.md) for current verification boundaries. Publishing is a separate maintainer action; creating or pushing the repository does not publish npm.
165
+ Force removal deletes only the fixed helper connection files, including modified contents, without backing up those modified contents. It still preserves original tool settings and runtime conversations. Use it only for recovery, not routine reconfiguration.
166
+
167
+ ## Configuration and privacy
168
+
169
+ - The shared API base URL is `https://1endpoint.dev/api/v1`.
170
+ - Connection configuration is stored under `~/.1endpoint/coding-helper`. Set an absolute `ONEENDPOINT_HELPER_HOME` if you need a different location.
171
+ - Setup does not overwrite your existing DSH or Claude Code configuration.
172
+ - The interactive menu fetches the public text-model catalog without sending your API key. Explicit setup, reconfigure and doctor commands work locally.
173
+ - Your key is supplied to the launched tool through its process environment, not saved in the helper's connection files.
174
+ - Runtime tools can create their own session data. Keep their isolated homes private.
175
+ - Configuration isolation is **not an operating-system sandbox**; the tools retain their ordinary filesystem and network access.
114
176
 
115
- ## Reference and attribution
177
+ This is an independent integration, not an official DeepSeek or Anthropic product.
116
178
 
117
- The onboarding/product design was reviewed against [Z.AI coding-helper](https://www.npmjs.com/package/@z_ai/coding-helper) `0.1.1`: wizard, per-tool setup and doctor. Its registry metadata labels the package Proprietary. No implementation code is copied; this is an independent implementation with an explicit test suite, no stored API keys, and no automatic tool installation. The reference has no DSH support and is not authority for the DSH configuration contract.
179
+ ## Troubleshooting
118
180
 
119
- Official references: [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Claude gateway setup](https://code.claude.com/docs/en/llm-gateway-connect), [settings](https://code.claude.com/docs/en/settings), [models](https://code.claude.com/docs/en/model-config).
181
+ | Problem | What to do |
182
+ | --- | --- |
183
+ | Node version error | Install Node.js 22.19 or newer, then reopen your terminal. |
184
+ | CLI not found | Install the selected coding tool and make sure its executable is on your `PATH`. |
185
+ | API key missing | Open the helper and paste a key, or press Enter on Open Console to create one. Choose Save locally for future launches. |
186
+ | Catalog unavailable | Choose Retry, or explicitly choose manual entry with a known public model ID. |
187
+ | New model is not taking effect | Close the old session and start a new one with the helper's Run action. |
188
+ | Ordinary tool launch ignores the helper | Launch through `npx @1endpoint/coding-helper run --tool dsh` or `--tool claude`. |
189
+ | Generated configuration is damaged | Use Remove → Force removal recovery, then Setup again. |
190
+ | Authentication or model request fails | Confirm your key, model access and account balance in 1endpoint. Doctor does not validate server-side access. |
120
191
 
121
- ## Tiếng Việt
192
+ ## More help
122
193
 
123
- Helper hỗ trợ ưu tiên DeepSeek Harness và Claude Code CLI; dùng một base URL chung, home riêng và API key từ biến môi trường. Không sửa cấu hình công cụ đang dùng, không lưu key, không tự cài công cụ và không tự gọi inference có phí. `setup --dry-run` xem trước; `doctor` chỉ kiểm tra cục bộ; `run` mới khởi chạy công cụ. Claude chạy ở chế độ này **không nạp settings/hook/quyền project-local**; chính sách quản trị vẫn có hiệu lực. Package chưa publish: dùng lệnh local ở trên cho tới khi maintainer phát hành. Hướng dẫn Console có cả EN/VI.
194
+ - [Console usage guide](https://console.1endpoint.dev/docs/guides/coding-tools/coding-helper)
195
+ - [Report a problem](https://github.com/dothinh115/1endpoint-coding-helper/issues)
196
+ - Maintainers: [publishing](docs/PUBLISHING.md) and [release verification](docs/RELEASE.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1endpoint/coding-helper",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Safe isolated 1endpoint setup for DeepSeek Harness and Claude Code CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ /** Explicitly open only the Console create-key page; never accepts a caller URL. */
4
+ export function openConsole({ language = 'en', spawnImpl = spawn, platform = process.platform } = {}) {
5
+ const url = language === 'vi' ? 'https://console.1endpoint.dev/vi/api-keys' : 'https://console.1endpoint.dev/api-keys'
6
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32.exe' : 'xdg-open'
7
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
8
+ return new Promise((resolve, reject) => {
9
+ let child, timer, settled = false
10
+ const fail = () => {
11
+ const error = new Error('Could not open Console. Try again or open the Console API keys page manually.')
12
+ error.code = 'BROWSER_OPEN_FAILED'
13
+ return error
14
+ }
15
+ const finish = error => {
16
+ if (settled) return
17
+ settled = true
18
+ clearTimeout(timer)
19
+ if (child) {
20
+ child.off('error', onError)
21
+ child.off('close', onClose)
22
+ // Absorb a late spawn error after timeout.
23
+ child.on('error', () => {})
24
+ }
25
+ if (error) reject(error)
26
+ else resolve()
27
+ }
28
+ const onError = () => finish(fail())
29
+ const onClose = code => finish(code === 0 ? undefined : fail())
30
+ try {
31
+ child = spawnImpl(command, args, { shell: false, stdio: 'ignore', windowsHide: true })
32
+ child.once('error', onError)
33
+ child.once('close', onClose)
34
+ timer = setTimeout(() => {
35
+ try { child.kill() } catch {}
36
+ finish(fail())
37
+ }, 5000)
38
+ } catch { finish(fail()) }
39
+ })
40
+ }
package/src/cli.mjs CHANGED
@@ -7,13 +7,14 @@ import { wizard, colorize } from './ui.mjs'
7
7
  import { setup, reconfigure, remove, backup, loadState, stateRoot, validateTool, assertSafePath } from './state.mjs'
8
8
  import { launchClaude, childEnvironment } from './adapters/claude.mjs'
9
9
  import { launchDsh } from './adapters/dsh.mjs'
10
+ import { getCredential, saveCredential, removeCredential } from './credentials.mjs'
10
11
 
11
12
  const HELP = `@1endpoint/coding-helper — isolated 1endpoint coding-tool setup
12
13
 
13
14
  Usage:
14
15
  coding-helper setup --tool dsh|claude --model PUBLIC_MODEL [--dry-run] [--update]
15
16
  coding-helper reconfigure --tool dsh|claude --model PUBLIC_MODEL [--dry-run]
16
- coding-helper remove --tool dsh|claude [--yes] [--dry-run] [--force]
17
+ coding-helper remove --tool dsh|claude [--yes] [--dry-run] [--force] [--remove-key]
17
18
  coding-helper doctor --tool dsh|claude
18
19
  coding-helper run --tool dsh|claude [--profile web|headless|acp|sdk] -- [tool args]
19
20
  coding-helper backup --tool dsh|claude
@@ -24,7 +25,9 @@ Wizard fetches public 1endpoint text models for independent tool/tier selections
24
25
  Claude setup/reconfigure accepts --haiku-model ID --sonnet-model ID --opus-model ID
25
26
  and optional --fable-model ID for supporting versions. --model is the main/default fallback.
26
27
  Node.js >=22.19 is required.
27
- API key: export ONEENDPOINT_API_KEY in your shell; never pass it as an argument.
28
+ Paste your API key privately in the menu, or press Enter on Open Console to create one.
29
+ Choose private local storage or session-only use. Saved keys are plaintext private files, not encrypted.
30
+ ONEENDPOINT_API_KEY remains an optional environment override; never pass keys as arguments.
28
31
  State: ONEENDPOINT_HELPER_HOME or ~/.1endpoint/coding-helper (isolated tool homes).
29
32
  No existing DSH/Claude settings are edited. Setup/doctor make no API requests.
30
33
  Claude launch excludes project/local settings, hooks and permissions; managed policy applies.
@@ -38,12 +41,12 @@ function parse(argv) {
38
41
  while (args.length) {
39
42
  const arg = args.shift()
40
43
  if (arg === '--') { forwarded.push(...args); break }
41
- if (['--dry-run', '--update', '--yes', '--force'].includes(arg)) { if (options[arg]) throw new Error('Duplicate option.'); options[arg] = true; continue }
44
+ if (['--dry-run', '--update', '--yes', '--force', '--remove-key'].includes(arg)) { if (options[arg]) throw new Error('Duplicate option.'); options[arg] = true; continue }
42
45
  if (!['--tool', '--model', '--profile', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'].includes(arg)) throw new Error('Unknown option. Use --help. API keys must never be command-line arguments.')
43
46
  if (options[arg] !== undefined || !args.length || args[0].startsWith('-')) throw new Error('Missing or duplicate option value.')
44
47
  options[arg] = args.shift()
45
48
  }
46
- const allowed = { setup: ['--tool', '--model', '--dry-run', '--update', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], reconfigure: ['--tool', '--model', '--dry-run', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], remove: ['--tool', '--yes', '--dry-run', '--force'], doctor: ['--tool'], backup: ['--tool'], run: ['--tool', '--profile'] }
49
+ const allowed = { setup: ['--tool', '--model', '--dry-run', '--update', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], reconfigure: ['--tool', '--model', '--dry-run', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], remove: ['--tool', '--yes', '--dry-run', '--force', '--remove-key'], doctor: ['--tool'], backup: ['--tool'], run: ['--tool', '--profile'] }
47
50
  if (!allowed[command] || Object.keys(options).some(key => !allowed[command].includes(key)) || (command !== 'run' && forwarded.length)) throw new Error('Invalid command/options. Use --help.')
48
51
  validateTool(options['--tool'])
49
52
  if (options['--tool'] !== 'claude' && ['haiku', 'sonnet', 'opus', 'fable'].some(role => options[`--${role}-model`])) throw new Error('Tier model flags are only supported for Claude.')
@@ -63,7 +66,10 @@ async function executable(command, env) {
63
66
  return null
64
67
  }
65
68
  async function main(argv, context = {}) {
66
- if (!argv.length) return wizard({ dispatch: main })
69
+ if (!argv.length) return wizard({ dispatch: main, getKeyStatus: async ({ tool }) => {
70
+ const credential = await getCredential({ stateDir: stateRoot(), tool })
71
+ return { available: Boolean(credential.key), source: credential.source }
72
+ } })
67
73
  if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { console.log(HELP); return }
68
74
  if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) {
69
75
  const metadata = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8'))
@@ -76,8 +82,12 @@ async function main(argv, context = {}) {
76
82
  const result = await remove({ stateDir, tool, dryRun: options['--dry-run'], yes: options['--yes'], force: options['--force'] })
77
83
  console.log(`${result.dryRun ? t('Preview removal', 'Xem trước việc xóa') : result.changed ? t('Removed helper configuration', 'Đã xóa cấu hình riêng') : t('Already removed', 'Cấu hình đã được xóa')}: ${tool}`)
78
84
  for (const file of result.files) console.log(` ${file}`)
85
+ if (options['--remove-key'] || context.removeKey) {
86
+ await removeCredential({ stateDir, tool, dryRun: Boolean(options['--dry-run']) })
87
+ console.log(options['--dry-run'] ? t('Saved-key removal preview only.', 'Chỉ xem trước việc xóa khóa đã lưu.') : t('Saved API key removed (if present).', 'Đã xóa khóa API đã lưu (nếu có).'))
88
+ }
79
89
  if (result.force) console.log(t('Forced recovery removes only fixed helper configuration paths; modified contents are not backed up.', 'Khôi phục cưỡng chế chỉ xóa đường dẫn cấu hình riêng cố định; nội dung đã sửa không được sao lưu.'))
80
- console.log(t('Existing user settings, runtime homes, backups and unknown files were preserved.', 'Đã giữ nguyên cài đặt gốc, dữ liệu phiên chạy, bản sao lưu và các tệp khác.'))
90
+ console.log(t('Original tool settings, conversations and backups are unchanged.', 'Cài đặt gốc, hội thoại và bản sao lưu được giữ nguyên.'))
81
91
  return
82
92
  }
83
93
  if (command === 'setup' || command === 'reconfigure') {
@@ -87,25 +97,31 @@ async function main(argv, context = {}) {
87
97
  for (const file of result.files) console.log(` ${file.path}`)
88
98
  if (context.language !== 'vi') for (const hint of result.hints || []) console.log(hint)
89
99
  else console.log(tool === 'claude' ? 'Không nạp cài đặt, hook và quyền project/local. Chính sách quản trị vẫn áp dụng; không bỏ qua quyền.' : 'DSH dùng home và cấu hình riêng; không sửa cài đặt gốc. Chế độ sdk-minimal không được hỗ trợ.')
90
- console.log(t('Set ONEENDPOINT_API_KEY in your shell, then use run --tool ', 'Đặt ONEENDPOINT_API_KEY trong môi trường shell, sau đó dùng run --tool ') + tool + t('. No key was saved.', '. Không lưu khóa API.'))
100
+ if (!result.dryRun && context.apiKey && context.saveKey) {
101
+ await saveCredential({ stateDir, tool, key: context.apiKey })
102
+ console.log(t('API key saved privately for this tool. Local storage is not encrypted.', 'Đã lưu khóa API riêng cho công cụ này. Tệp lưu cục bộ không được mã hóa.'))
103
+ } else if (context.apiKey) console.log(t('API key is available for this session only.', 'Khóa API chỉ dùng trong phiên này.'))
104
+ else console.log(t('Use the interactive menu to paste an API key, or provide ONEENDPOINT_API_KEY in your environment.', 'Mở menu để dán khóa API, hoặc cung cấp ONEENDPOINT_API_KEY qua môi trường.'))
91
105
  if (!result.dryRun && !context.language) console.log(colorize('RESTART REQUIRED: existing sessions are not updated. Start a new session through coding-helper run --tool ' + tool + '.', 'warning'))
92
106
  return
93
107
  }
94
108
  if (command === 'backup') { console.log('Secret-free configuration backup: ' + await backup({ stateDir, tool })); return }
95
109
  const { state, plan } = await loadState(stateDir, tool)
110
+ const credential = context.apiKey ? { key: context.apiKey, source: 'session' } : await getCredential({ stateDir, tool })
96
111
  const bin = await executable(plan.command, process.env)
97
112
  if (command === 'doctor') {
98
113
  console.log(`${t('Configuration integrity: OK', 'Tính toàn vẹn cấu hình: OK')} (${tool})`)
99
114
  console.log(`${t('CLI installed', 'Đã cài CLI')}: ${bin ? t('yes', 'có') : t('no', 'chưa')}`)
100
- console.log(`ONEENDPOINT_API_KEY: ${process.env.ONEENDPOINT_API_KEY ? t('present (not validated)', 'đã có (chưa xác thực)') : t('missing', 'chưa có')}`)
115
+ console.log(`${t('API key', 'Khóa API')}: ${credential.key ? t('available (not validated)', 'đã có (chưa xác thực)') : t('missing', 'chưa có')}`)
101
116
  console.log(t('No credentials read from tool files. No network or inference check performed.', 'Không đọc thông tin đăng nhập từ tệp công cụ. Không kiểm tra mạng hoặc gọi mô hình.'))
102
117
  if (context.language !== 'vi') for (const hint of plan.hints || []) console.log(hint)
103
- if (!bin || !process.env.ONEENDPOINT_API_KEY) process.exitCode = 1
118
+ if (!bin || !credential.key) process.exitCode = 1
104
119
  return
105
120
  }
106
121
  if (!bin) throw new Error('CLI not found on PATH. Install the tool from its official distribution first.')
107
- const key = process.env.ONEENDPOINT_API_KEY
108
- if (!key || /[\r\n\0]/.test(key)) throw new Error('Set a valid ONEENDPOINT_API_KEY in the environment. It is never saved.')
122
+ const key = credential.key
123
+ if (!key || /[\s\x00-\x1f\x7f-\x9f]/.test(key)) throw new Error(t('Add an API key through the interactive menu or set ONEENDPOINT_API_KEY.', 'Thêm khóa API qua menu hoặc đặt ONEENDPOINT_API_KEY.'))
124
+ if (context.apiKey && context.saveKey) await saveCredential({ stateDir, tool, key })
109
125
  let launch
110
126
  try { launch = (tool === 'dsh' ? launchDsh : launchClaude)({ stateDir, model: state.model, roleModels: state.roles, args: forwarded, profile: options['--profile'] || 'web' }) }
111
127
  catch { throw new Error('Unsupported tool arguments: routing, agent/plugin and permission-bypass overrides are not allowed.') }
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs/promises'
2
+ import { constants } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { assertSafePath, validateTool } from './state.mjs'
6
+
7
+ // Opt-in local storage is plaintext, NOT encryption. Credentials are kept only in
8
+ // credentials/<tool>.json (0600), inside credentials/ (0700), never manifests or
9
+ // backups. Atomic replacement leaves the previous credential intact on failure.
10
+ const failure = () => new Error('Unable to access a safe credential store. Check ownership, permissions, and saved credential format.')
11
+ const invalidKey = () => new Error('Provide a valid API key token without whitespace or control characters.')
12
+ const MAX_BYTES = 16384
13
+ const posix = process.platform !== 'win32'
14
+
15
+ function token(key) {
16
+ if (typeof key !== 'string' || !key.length || key.length > 8192 || /[\s\p{Cc}\p{Cf}]/u.test(key)) throw invalidKey()
17
+ return key
18
+ }
19
+ function locations(stateDir, tool) {
20
+ validateTool(tool)
21
+ if (typeof stateDir !== 'string' || !stateDir.length) throw failure()
22
+ const root = path.resolve(stateDir)
23
+ return { root, dir: path.join(root, 'credentials'), file: path.join(root, 'credentials', `${tool}.json`), lock: path.join(root, '.setup.lock') }
24
+ }
25
+ function owned(stat) {
26
+ if (posix && typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw failure()
27
+ }
28
+ function privateFile(stat) {
29
+ owned(stat)
30
+ if (!stat.isFile() || stat.nlink !== 1 || (posix && (stat.mode & 0o7777) !== 0o600) || stat.size > MAX_BYTES) throw failure()
31
+ }
32
+ async function optionalStat(file) {
33
+ try { return await fs.lstat(file) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
34
+ }
35
+ async function inspect(paths) {
36
+ await assertSafePath(paths.file)
37
+ const root = await optionalStat(paths.root)
38
+ if (root) {
39
+ owned(root)
40
+ if (!root.isDirectory() || (posix && (root.mode & 0o022))) throw failure()
41
+ }
42
+ const dir = await optionalStat(paths.dir)
43
+ if (dir) {
44
+ owned(dir)
45
+ if (!dir.isDirectory() || (posix && (dir.mode & 0o7777) !== 0o700)) throw failure()
46
+ }
47
+ const stat = await optionalStat(paths.file)
48
+ if (stat) privateFile(stat)
49
+ return stat
50
+ }
51
+ function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino }
52
+ async function readSaved(paths, tool) {
53
+ const stat = await inspect(paths)
54
+ if (!stat) return null
55
+ const handle = await fs.open(paths.file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
56
+ try {
57
+ const opened = await handle.stat()
58
+ privateFile(opened)
59
+ if (!sameFile(stat, opened)) throw failure()
60
+ const raw = await handle.readFile('utf8')
61
+ const after = await handle.stat()
62
+ privateFile(after)
63
+ if (Buffer.byteLength(raw) > MAX_BYTES || !sameFile(opened, after)) throw failure()
64
+ const current = await inspect(paths)
65
+ if (!current || !sameFile(opened, current)) throw failure()
66
+ const saved = JSON.parse(raw)
67
+ if (!saved || typeof saved !== 'object' || Array.isArray(saved) || Object.keys(saved).sort().join(',') !== 'key,tool,version' || saved.version !== 1 || saved.tool !== tool) throw failure()
68
+ return token(saved.key)
69
+ } finally { await handle.close() }
70
+ }
71
+
72
+ export async function getCredential({ stateDir, tool, env = process.env }) {
73
+ validateTool(tool)
74
+ const key = env.ONEENDPOINT_API_KEY
75
+ // An invalid nonempty environment token must not silently fall back to disk.
76
+ if (key !== undefined && key !== '') return { key: token(key), source: 'environment' }
77
+ try {
78
+ const saved = await readSaved(locations(stateDir, tool), tool)
79
+ return saved === null ? { key: undefined, source: 'missing' } : { key: saved, source: 'saved' }
80
+ } catch { throw failure() }
81
+ }
82
+
83
+ export async function credentialExists({ stateDir, tool }) {
84
+ return (await getCredential({ stateDir, tool, env: {} })).source === 'saved'
85
+ }
86
+
87
+ async function withLock(paths, operation) {
88
+ await inspect(paths)
89
+ await fs.mkdir(paths.root, { recursive: true, mode: 0o700 })
90
+ await inspect(paths)
91
+ await assertSafePath(paths.lock)
92
+ let lock
93
+ try { lock = await fs.open(paths.lock, 'wx', 0o600) } catch (error) {
94
+ if (error.code === 'EEXIST') throw new Error('Another setup may be active. Review the helper lock before retrying.')
95
+ throw error
96
+ }
97
+ try { return await operation() } finally {
98
+ const held = await lock.stat()
99
+ await lock.close()
100
+ await assertSafePath(paths.lock)
101
+ const current = await optionalStat(paths.lock)
102
+ if (!current || !sameFile(held, current)) throw failure()
103
+ await fs.unlink(paths.lock)
104
+ }
105
+ }
106
+
107
+ export async function saveCredential({ stateDir, tool, key }) {
108
+ const paths = locations(stateDir, tool)
109
+ token(key)
110
+ try {
111
+ return await withLock(paths, async () => {
112
+ const previous = await readSaved(paths, tool)
113
+ if (previous === key) return { changed: false }
114
+ await fs.mkdir(paths.dir, { mode: 0o700, recursive: true })
115
+ await inspect(paths)
116
+ const temp = path.join(paths.dir, `.${tool}-${randomUUID()}.tmp`)
117
+ let handle
118
+ try {
119
+ handle = await fs.open(temp, 'wx', 0o600)
120
+ await handle.chmod(0o600)
121
+ await handle.writeFile(JSON.stringify({ version: 1, tool, key }) + '\n', 'utf8')
122
+ await handle.sync()
123
+ await handle.close()
124
+ handle = null
125
+ // The only commit is rename: all fallible preparation happens first.
126
+ await inspect(paths)
127
+ await assertSafePath(temp)
128
+ privateFile(await fs.lstat(temp))
129
+ await fs.rename(temp, paths.file)
130
+ return { changed: true }
131
+ } finally {
132
+ if (handle) await handle.close()
133
+ await fs.unlink(temp).catch(error => { if (error.code !== 'ENOENT') throw error })
134
+ }
135
+ })
136
+ } catch (error) {
137
+ if (error.message === 'Another setup may be active. Review the helper lock before retrying.') throw error
138
+ throw failure()
139
+ }
140
+ }
141
+
142
+ export async function removeCredential({ stateDir, tool, dryRun = false }) {
143
+ const paths = locations(stateDir, tool)
144
+ try {
145
+ const present = await inspect(paths)
146
+ if (dryRun || !present) return { changed: false, dryRun }
147
+ return await withLock(paths, async () => {
148
+ const current = await inspect(paths)
149
+ if (!current) return { changed: false, dryRun: false }
150
+ // Fixed validated path only; malformed JSON can be safely removed too.
151
+ await fs.unlink(paths.file)
152
+ return { changed: true, dryRun: false }
153
+ })
154
+ } catch (error) {
155
+ if (error.message === 'Another setup may be active. Review the helper lock before retrying.') throw error
156
+ throw failure()
157
+ }
158
+ }
package/src/ui.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from 'node:readline/promises'
2
2
  import { fetchModels } from './catalog.mjs'
3
+ import { openConsole } from './browser.mjs'
3
4
  import * as claudeAdapter from './adapters/claude.mjs'
4
5
 
5
6
  const palette = { heading: 36, info: 34, success: 32, warning: 33, error: 31, muted: 90, selected: 35 }
@@ -31,12 +32,19 @@ export async function select({ message, choices, input = process.stdin, output =
31
32
  const wasPaused = typeof input.isPaused === 'function' && input.isPaused()
32
33
  let index = 0, rendered = false, buffer = '', timer, settled = false
33
34
  const pageSize = Math.min(choices.length, 8, Math.max(1, (output.rows || 24) - 4))
34
- const lines = pageSize + 2
35
- // Conservative cell budget also prevents wrapping wide Unicode labels.
36
- const fit = text => {
37
- const chars = Array.from(clean(text))
38
- const limit = Math.max(8, Math.floor((output.columns || 120) / 2) - 1)
39
- return chars.length > limit ? chars.slice(0, limit - 1).join('') + '…' : chars.join('')
35
+ let renderedLines = 0
36
+ const wrap = text => {
37
+ const width = Math.max(4, (output.columns || 120) - 1)
38
+ const lines = ['']
39
+ let cells = 0
40
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(clean(text))) {
41
+ const cp = segment.codePointAt(0)
42
+ const size = /^\p{Mark}+$/u.test(segment) ? 0 : /\p{Extended_Pictographic}/u.test(segment) || cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff || cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0x20000) ? 2 : 1
43
+ if (cells + size > width) { lines.push(''); cells = 0 }
44
+ lines[lines.length - 1] += segment
45
+ cells += size
46
+ }
47
+ return lines
40
48
  }
41
49
  return new Promise((resolve, reject) => {
42
50
  const cleanup = () => {
@@ -59,16 +67,24 @@ export async function select({ message, choices, input = process.stdin, output =
59
67
  }
60
68
  function onFailure() { finish(uiError()) }
61
69
  function draw() {
62
- if (rendered) output.write(`\u001b[${lines}A\r`)
63
- output.write('\u001b[2K' + colorize(fit(message), 'heading', { output }) + '\n')
70
+ if (rendered) output.write(`\u001b[${renderedLines}A\r\u001b[J`)
71
+ const rows = wrap(message).map(text => ({ text, kind: 'heading' }))
64
72
  const start = Math.min(Math.max(0, index - pageSize + 1), choices.length - pageSize)
65
- choices.slice(start, start + pageSize).forEach((choice, offset) => {
66
- const i = start + offset
67
- const text = `${i === index ? '❯' : ' '} ${choice.label}${choice.description ? ` — ${choice.description}` : ''}`
68
- output.write('\u001b[2K' + colorize(fit(text), i === index ? 'selected' : 'info', { output }) + '\n')
69
- })
70
73
  const hint = language === 'vi' ? '↑/↓ chọn · Enter xác nhận · Esc hủy' : '↑/↓ select · Enter accept · Esc cancel'
71
- output.write('\u001b[2K' + colorize(fit(`${index + 1}/${choices.length} · ${hint}`), 'muted', { output }) + '\n')
74
+ const footer = wrap(`${index + 1}/${choices.length} · ${hint}`)
75
+ let candidates = choices.slice(start, start + pageSize).map((choice, offset) => ({
76
+ i: start + offset,
77
+ lines: wrap(`${start + offset === index ? '❯' : ' '} ${choice.label}${choice.description ? ` — ${choice.description}` : ''}`)
78
+ }))
79
+ const budget = Math.max(1, (output.rows || 24) - rows.length - footer.length - 1)
80
+ while (candidates.length > 1 && candidates.reduce((n, c) => n + c.lines.length, 0) > budget) {
81
+ if (candidates[0].i < index) candidates.shift()
82
+ else candidates.pop()
83
+ }
84
+ for (const candidate of candidates) for (const text of candidate.lines) rows.push({ text, kind: candidate.i === index ? 'selected' : 'info' })
85
+ for (const text of footer) rows.push({ text, kind: 'muted' })
86
+ for (const { text, kind } of rows) output.write('\u001b[2K' + colorize(text, kind, { output }) + '\n')
87
+ renderedLines = rows.length
72
88
  rendered = true
73
89
  }
74
90
  function onData(chunk) {
@@ -114,6 +130,81 @@ export async function select({ message, choices, input = process.stdin, output =
114
130
  })
115
131
  }
116
132
 
133
+ /** Masked single-token entry. Bracketed paste is buffered until its end marker. */
134
+ export async function readApiKey({ input = process.stdin, output = process.stdout, language = 'en' } = {}) {
135
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') throw uiError()
136
+ const wasRaw = Boolean(input.isRaw), wasPaused = input.isPaused?.()
137
+ let value = '', buffer = '', pasting = false, pasted = '', settled = false, timer
138
+ return new Promise((resolve, reject) => {
139
+ const finish = (error, result) => {
140
+ if (settled) return
141
+ settled = true
142
+ clearTimeout(timer)
143
+ for (const event of ['data', 'error', 'end', 'close']) input.off(event, event === 'data' ? onData : onFailure)
144
+ output.off('error', onFailure)
145
+ try { input.setRawMode(wasRaw) } catch {}
146
+ try { if (wasPaused) input.pause() } catch {}
147
+ try { output.write('\u001b[?2004l\n') } catch {}
148
+ value = ''; pasted = ''; buffer = ''
149
+ if (error) reject(error)
150
+ else resolve(result)
151
+ }
152
+ const invalid = () => {
153
+ const error = new Error(language === 'vi' ? 'Khóa API phải là một chuỗi duy nhất, không có khoảng trắng hoặc ký tự điều khiển.' : 'API key must be a single token without whitespace or control characters.')
154
+ error.code = 'UI_INVALID_KEY'
155
+ finish(error)
156
+ }
157
+ const valid = text => /^[\x21-\x7e]+$/.test(text) && text.length <= 8192
158
+ function onFailure() { finish(uiError()) }
159
+ function onData(chunk) {
160
+ try {
161
+ clearTimeout(timer)
162
+ buffer += chunk.toString('utf8')
163
+ while (buffer && !settled) {
164
+ if (pasting) {
165
+ const end = buffer.indexOf('\u001b[201~')
166
+ if (end < 0) {
167
+ // Retain a possible split terminator; no pasted byte is interpreted as a key.
168
+ if (buffer.length > 6) { pasted += buffer.slice(0, -6); buffer = buffer.slice(-6) }
169
+ if (pasted.length > 8192) return invalid()
170
+ return
171
+ }
172
+ pasted += buffer.slice(0, end)
173
+ buffer = buffer.slice(end + 6)
174
+ if (!valid(pasted) || value.length + pasted.length > 8192) return invalid()
175
+ value += pasted; output.write('*'.repeat(pasted.length)); pasted = ''; pasting = false
176
+ continue
177
+ }
178
+ if (buffer.startsWith('\u001b[200~')) { buffer = buffer.slice(6); pasting = true; continue }
179
+ if ('\u001b[200~'.startsWith(buffer) && buffer[0] === '\u001b') {
180
+ timer = setTimeout(() => finish(uiError(true)), 35); return
181
+ }
182
+ const char = buffer[0]; buffer = buffer.slice(1)
183
+ if (char === '\u0003' || char === '\u001b') return finish(uiError(true))
184
+ if (char === '\r' || char === '\n') {
185
+ if (!valid(value) || buffer.length) return invalid()
186
+ return finish(null, value)
187
+ }
188
+ if (char === '\x7f' || char === '\b') {
189
+ if (value) { value = value.slice(0, -1); output.write('\b \b') }
190
+ continue
191
+ }
192
+ if (!valid(char) || value.length >= 8192) return invalid()
193
+ value += char; output.write('*')
194
+ }
195
+ } catch { finish(uiError()) }
196
+ }
197
+ try {
198
+ input.on('data', onData)
199
+ for (const event of ['error', 'end', 'close']) input.on(event, onFailure)
200
+ output.on('error', onFailure)
201
+ input.setRawMode(true)
202
+ output.write('\u001b[?2004h' + (language === 'vi' ? 'Dán khóa API (ẩn): ' : 'Paste API key (masked): '))
203
+ input.resume()
204
+ } catch { finish(uiError()) }
205
+ })
206
+ }
207
+
117
208
  async function modelQuestion(input, output, language = 'en') {
118
209
  const controller = new AbortController()
119
210
  const terminal = createInterface({ input, output, terminal: true })
@@ -139,7 +230,7 @@ async function modelQuestion(input, output, language = 'en') {
139
230
  }
140
231
 
141
232
  /** Gather CLI arguments only. State and tool execution belong to dispatch. */
142
- export async function wizard({ dispatch, input = process.stdin, output = process.stdout, loadModels = fetchModels }) {
233
+ export async function wizard({ dispatch, input = process.stdin, output = process.stdout, loadModels = fetchModels, getKeyStatus = async () => ({ available: false }), openConsole: openBrowser = openConsole }) {
143
234
  if (typeof dispatch !== 'function') throw new Error('Wizard requires a CLI dispatcher.')
144
235
  let language = 'en'
145
236
  const t = (en, vi) => language === 'vi' ? vi : en
@@ -152,7 +243,7 @@ export async function wizard({ dispatch, input = process.stdin, output = process
152
243
  language = await choose('Language / Ngôn ngữ', [
153
244
  { label: 'English', value: 'en' }, { label: 'Tiếng Việt', value: 'vi' }
154
245
  ])
155
- const action = await choose(t('1endpoint coding-helper · isolated configuration; no API key is requested or saved', '1endpoint coding-helper · cấu hình riêng; không yêu cầu hoặc lưu khóa API'), [
246
+ const action = await choose(t('1endpoint coding-helper · isolated configuration', '1endpoint coding-helper · cấu hình riêng'), [
156
247
  { label: t('Setup', 'Cấu hình'), value: 'setup' },
157
248
  { label: t('Reconfigure', 'Cấu hình lại'), value: 'reconfigure' },
158
249
  { label: t('Remove', 'Xóa cấu hình'), value: 'remove' },
@@ -165,6 +256,44 @@ export async function wizard({ dispatch, input = process.stdin, output = process
165
256
  { label: 'DSH', value: 'dsh' }, { label: 'Claude Code', value: 'claude' }
166
257
  ])
167
258
  const argv = [action, '--tool', tool]
259
+ const context = { language }
260
+ if (['setup', 'reconfigure', 'run'].includes(action)) {
261
+ let available = false
262
+ try { available = (await getKeyStatus({ tool }))?.available === true } catch {}
263
+ if (action !== 'run' || !available) {
264
+ while (true) {
265
+ const keyAction = await choose(t('API key', 'Khóa API'), [
266
+ { label: t('Paste key (masked)', 'Dán khóa (ẩn)'), value: 'paste' },
267
+ { label: t('Open Console create API key', 'Mở Console tạo khóa API'), value: 'open', description: t('Enter opens the API keys page', 'Enter mở trang khóa API') },
268
+ ...(available ? [{ label: t('Use existing saved/environment key', 'Dùng khóa đã lưu hoặc biến môi trường'), value: 'existing' }] : []),
269
+ { label: t('Skip; use environment key later', 'Bỏ qua; dùng khóa môi trường sau'), value: 'skip' },
270
+ { label: t('Cancel', 'Hủy'), value: 'cancel' }
271
+ ])
272
+ if (keyAction === 'cancel') return
273
+ if (keyAction === 'existing' || keyAction === 'skip') break
274
+ if (keyAction === 'open') {
275
+ try { await openBrowser({ language }) }
276
+ catch { output.write(t('Could not open Console. Select Open Console to retry, or paste a key.\n', 'Không mở được Console. Chọn Mở Console để thử lại hoặc dán khóa.\n')) }
277
+ continue
278
+ }
279
+ let key
280
+ try { key = await readApiKey({ input, output, language }) }
281
+ catch (error) {
282
+ if (error.code !== 'UI_INVALID_KEY') throw error
283
+ output.write(error.message + '\n'); continue
284
+ }
285
+ output.write(t('Local keys are plaintext, mode 0600: accessible to your user account, not encrypted.\n', 'Khóa cục bộ ở dạng văn bản thuần, quyền 0600: tài khoản của bạn có thể đọc, không được mã hóa.\n'))
286
+ const storage = await choose(t('Key storage', 'Lưu khóa'), [
287
+ { label: t('Save locally', 'Lưu cục bộ'), value: 'save' },
288
+ { label: t('This run only', 'Chỉ lần chạy này'), value: 'session' },
289
+ { label: t('Cancel', 'Hủy'), value: 'cancel' }
290
+ ])
291
+ if (storage === 'cancel') return
292
+ context.apiKey = key; context.saveKey = storage === 'save'
293
+ break
294
+ }
295
+ }
296
+ }
168
297
  if (action === 'setup' || action === 'reconfigure') {
169
298
  let models, manual = false
170
299
  while (!models && !manual) {
@@ -192,7 +321,7 @@ export async function wizard({ dispatch, input = process.stdin, output = process
192
321
  const optionalRoles = claudeAdapter.CLAUDE_OPTIONAL_ROLES || []
193
322
  let activeRoles = [...roles]
194
323
  if (optionalRoles.length) {
195
- const extra = await choose(t('Claude tier support depends on your installed version; no version is guessed.', 'Các cấp Claude tùy thuộc phiên bản đã cài; không tự suy đoán phiên bản.'), [
324
+ const extra = await choose(t('Select the tiers supported by your Claude Code version.', 'Chọn các cấp được phiên bản Claude Code của bạn hỗ trợ.'), [
196
325
  { label: t('Standard Haiku / Sonnet / Opus', 'Các cấp chuẩn Haiku / Sonnet / Opus'), value: false },
197
326
  { label: t('Include optional tiers', 'Thêm cấp tùy chọn'), value: true, description: t('Only if your installed Claude supports ', 'Chỉ chọn nếu Claude đã cài hỗ trợ ') + optionalRoles.map(role => role.label).join(', ') }
198
327
  ])
@@ -224,16 +353,28 @@ export async function wizard({ dispatch, input = process.stdin, output = process
224
353
  ])
225
354
  if (mode === 'cancel') return
226
355
  if (mode === 'force') output.write(colorize(t('WARNING: Force recovery deletes fixed helper configuration paths, including modified contents, without backup.\n', 'CẢNH BÁO: xóa cưỡng chế xóa các đường dẫn cấu hình riêng cố định, kể cả nội dung đã sửa, không sao lưu.\n'), 'warning', { output }))
356
+ context.removeKey = await choose(t('Saved key for this tool', 'Khóa đã lưu cho công cụ này'), [
357
+ { label: t('Keep saved key', 'Giữ khóa đã lưu'), value: false },
358
+ { label: t('Remove saved key too', 'Xóa cả khóa đã lưu'), value: true }
359
+ ])
227
360
  if (!await confirm(mode === 'force' ? t('Confirm forced recovery deletion without backup?', 'Xác nhận xóa cưỡng chế không sao lưu?') : t('Confirm removal of helper-owned configuration?', 'Xác nhận xóa cấu hình riêng?'))) return
228
361
  argv.push('--yes')
229
362
  if (mode === 'force') argv.push('--force')
230
363
  }
231
- const result = await dispatch(argv, { language })
364
+ const result = await dispatch(argv, context)
232
365
  if (action === 'setup' || action === 'reconfigure') {
233
366
  const warning = t(`Restart your ${tool === 'dsh' ? 'DSH' : 'Claude Code'} session to apply. Existing sessions do not update automatically. Start a new session through coding-helper.\n`, `Khởi động lại phiên ${tool === 'dsh' ? 'DSH' : 'Claude Code'} để áp dụng. Phiên hiện có không tự cập nhật. Mở phiên mới thông qua coding-helper.\n`)
234
367
  const colored = colorize(warning, 'warning', { output })
235
368
  output.write(colored === warning ? warning : `\u001b[1m${colored}\u001b[0m`)
236
369
  }
370
+ if ((action === 'setup' || action === 'reconfigure') && context.apiKey && !context.saveKey) {
371
+ output.write(t('This key is only available now. Paste it again when reopening coding-helper.\n', 'Khóa chỉ có trong lần chạy này. Dán lại khi mở coding-helper lần sau.\n'))
372
+ const launch = await choose(t('Start a new session?', 'Mở phiên mới?'), [
373
+ { label: t('Finish', 'Hoàn tất'), value: false },
374
+ { label: t('Start new session now (may incur charges)', 'Mở phiên mới ngay (có thể phát sinh phí)'), value: true }
375
+ ])
376
+ if (launch) return dispatch(['run', '--tool', tool], { language, apiKey: context.apiKey, saveKey: false })
377
+ }
237
378
  return result
238
379
  } catch (error) {
239
380
  if (error.code === 'UI_CANCELLED') {