@1endpoint/coding-helper 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +122 -2
- package/docs/PUBLISHING.md +153 -0
- package/docs/RELEASE.md +47 -0
- package/package.json +43 -4
- package/src/adapters/claude.mjs +43 -0
- package/src/adapters/dsh.mjs +75 -0
- package/src/catalog.mjs +53 -0
- package/src/cli.mjs +130 -0
- package/src/state.mjs +172 -0
- package/src/ui.mjs +245 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1endpoint 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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @1endpoint/coding-helper
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Safe, isolated setup for **DeepSeek Harness** and **Claude Code CLI** using the single 1endpoint API base URL:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
https://1endpoint.dev/api/v1
|
|
7
|
+
```
|
|
8
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
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.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
After publication:
|
|
21
|
+
|
|
22
|
+
```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
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Before publication, from the repository:
|
|
35
|
+
|
|
36
|
+
```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
|
|
41
|
+
```
|
|
42
|
+
|
|
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.**
|
|
44
|
+
|
|
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.
|
|
46
|
+
|
|
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.
|
|
48
|
+
|
|
49
|
+
## Diagnose and launch
|
|
50
|
+
|
|
51
|
+
After setting the key in your process environment:
|
|
52
|
+
|
|
53
|
+
```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'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For npx-only use, replace `coding-helper` with `npx @1endpoint/coding-helper`. For local use, replace it with `node src/cli.mjs`.
|
|
63
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
### DeepSeek Harness
|
|
67
|
+
|
|
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.
|
|
75
|
+
|
|
76
|
+
### Claude Code CLI
|
|
77
|
+
|
|
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.
|
|
85
|
+
|
|
86
|
+
## Update, backup and recovery
|
|
87
|
+
|
|
88
|
+
```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
|
|
94
|
+
```
|
|
95
|
+
|
|
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.
|
|
97
|
+
|
|
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.
|
|
99
|
+
|
|
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.
|
|
101
|
+
|
|
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.
|
|
103
|
+
|
|
104
|
+
## Development and packaging
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
yarn install --immutable
|
|
108
|
+
yarn check
|
|
109
|
+
yarn test
|
|
110
|
+
yarn pack --out ../coding-helper-0.1.0.tgz
|
|
111
|
+
```
|
|
112
|
+
|
|
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.
|
|
114
|
+
|
|
115
|
+
## Reference and attribution
|
|
116
|
+
|
|
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.
|
|
118
|
+
|
|
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).
|
|
120
|
+
|
|
121
|
+
## Tiếng Việt
|
|
122
|
+
|
|
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.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Publishing @1endpoint/coding-helper to npm
|
|
2
|
+
|
|
3
|
+
This guide is for maintainers publishing the package, not for end users configuring a coding tool. For tool setup, see the [README](../README.md). For implementation and verification boundaries, see the [release checklist](RELEASE.md).
|
|
4
|
+
|
|
5
|
+
**Publishing is a separate action from pushing to GitHub.** Run the publish command only when you intend to release the selected version publicly. This document does not mean that a release has already been published.
|
|
6
|
+
|
|
7
|
+
## 1. Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js **22.19.0 or newer**.
|
|
10
|
+
- Yarn **4.17.0**, matching the repository's `packageManager` setting.
|
|
11
|
+
- An npm account authorized to publish under the **`@1endpoint`** scope.
|
|
12
|
+
- Access to the account's required two-factor authentication method.
|
|
13
|
+
- A reviewed, committed checkout of [the repository](https://github.com/dothinh115/1endpoint-coding-helper).
|
|
14
|
+
|
|
15
|
+
The npm scope and GitHub owner are independent. Owning the GitHub repository does not grant npm publishing rights. If `1endpoint` is an npm organization, the publishing account must be a member with package creation/publish permissions. Create or obtain access to that scope before continuing; do not change the package name merely to work around a permission failure.
|
|
16
|
+
|
|
17
|
+
Never put npm passwords, access tokens, recovery codes, or one-time codes in source files, issue comments, screenshots, or chat. Use the package manager's authentication flow. The helper's `ONEENDPOINT_API_KEY` is **not** an npm credential and is not needed for publication.
|
|
18
|
+
|
|
19
|
+
## 2. Open the checkout and authenticate
|
|
20
|
+
|
|
21
|
+
From this workspace:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cd /Users/thinhdo/Documents/1endpoint/coding-helper
|
|
25
|
+
node --version
|
|
26
|
+
yarn --version
|
|
27
|
+
|
|
28
|
+
yarn npm login --scope 1endpoint --publish
|
|
29
|
+
yarn npm whoami --scope 1endpoint --publish
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Follow the login prompts privately. The final command should identify the intended publishing account. Authentication alone does not prove permission to create this scoped package.
|
|
33
|
+
|
|
34
|
+
## 3. Review the version and source
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git status --short
|
|
38
|
+
git log -1 --oneline
|
|
39
|
+
node --input-type=module -e 'import fs from "node:fs"; const p = JSON.parse(fs.readFileSync("package.json", "utf8")); console.log(`${p.name}@${p.version}`)'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The worktree should be clean, and the name/version must be the release you intend to publish. The initial package version is `0.1.0`.
|
|
43
|
+
|
|
44
|
+
Optionally inspect the registry before releasing:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
yarn npm info @1endpoint/coding-helper --fields name,version,versions
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Before the first publication, a package-not-found response can be expected. It does **not** establish that your account owns the scope. If the target version already exists, do not retry publishing that same version: published versions are immutable, even after unpublishing. Prepare a new version instead.
|
|
51
|
+
|
|
52
|
+
## 4. Run the release checks
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
yarn install --immutable
|
|
56
|
+
yarn check
|
|
57
|
+
yarn test
|
|
58
|
+
yarn pack --dry-run
|
|
59
|
+
git diff --check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
There is no separate compilation step: the package ships native ESM source. `prepack` also runs the syntax check and test suite when packaging/publishing.
|
|
63
|
+
|
|
64
|
+
The ordinary test suite deliberately skips the opt-in installed-Claude loopback fixture. To run that fixture on a machine with Claude Code installed, set its absolute executable path:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
CODING_HELPER_TEST_CLAUDE="$(command -v claude)" yarn test
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This fixture uses a local synthetic server and dummy credentials, not paid gateway inference. Review skips and failures; a test run does not establish support for operating systems or CLI versions that were not exercised.
|
|
71
|
+
|
|
72
|
+
Inspect the package file list. It should contain only package metadata, the license, README, documentation and the intended source modules. Do not ship environment files, credentials, helper runtime homes, test output or unrelated workspace files.
|
|
73
|
+
|
|
74
|
+
## 5. Publish the package publicly
|
|
75
|
+
|
|
76
|
+
For the initial stable release:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
yarn npm publish --access public --tag latest
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If npm requests an authenticator code, follow the prompt or provide a current code using the supported option:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
yarn npm publish --access public --tag latest --otp YOUR_CURRENT_CODE
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Replace the placeholder privately. Be aware that command arguments may be visible in shell history or process listings; prefer an interactive prompt when available. Never copy the real code into documentation or chat.
|
|
89
|
+
|
|
90
|
+
`--access public` is intentional for this scoped package. `--tag latest` makes the release the default installed version. For a prerelease, use a prerelease version and a separate tag such as `next`; do not accidentally move `latest` to an experimental build.
|
|
91
|
+
|
|
92
|
+
**Do not repeat the publish command blindly after an ambiguous network failure.** Check whether the version reached the registry first.
|
|
93
|
+
|
|
94
|
+
## 6. Verify the public release
|
|
95
|
+
|
|
96
|
+
For version `0.1.0`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
yarn npm info @1endpoint/coding-helper --fields name,version,bin,dist
|
|
100
|
+
npx @1endpoint/coding-helper@0.1.0 --version
|
|
101
|
+
npx @1endpoint/coding-helper@0.1.0 --help
|
|
102
|
+
npx @1endpoint/coding-helper@0.1.0
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Verify:
|
|
106
|
+
|
|
107
|
+
1. Registry metadata reports the expected name and version, binary entries, and tarball integrity.
|
|
108
|
+
2. `--version` prints `0.1.0` and `--help` lists the documented commands.
|
|
109
|
+
3. The interactive entry asks for **English / Tiếng Việt first**.
|
|
110
|
+
4. Arrow keys and Enter select actions; confirmation does not require typing `y/n`.
|
|
111
|
+
5. Setup offers the public 1endpoint text-model catalog, with independent Claude tier mappings.
|
|
112
|
+
6. Setup/reconfigure emphasizes restarting the session through the helper.
|
|
113
|
+
|
|
114
|
+
Do the interactive smoke in a fresh environment or set `ONEENDPOINT_HELPER_HOME` to a new absolute test directory. Setup does not require a real API key. Do not launch inference merely to verify that npm installation works. Use the helper's `remove` action to remove its generated connection configuration afterward; runtime data is intentionally preserved.
|
|
115
|
+
|
|
116
|
+
Registry propagation and local package-manager caches can briefly lag. Check the exact pinned version before assuming a publish failed. After propagation, also verify the unpinned entry resolves to the intended `latest` release:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx @1endpoint/coding-helper --version
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 7. Finish the release handoff
|
|
123
|
+
|
|
124
|
+
- Record the released version, source commit, registry integrity and verification outcome.
|
|
125
|
+
- Ensure public setup documentation matches the released CLI rather than unreleased local changes.
|
|
126
|
+
- Deploy Console documentation separately through its normal pipeline if needed. Removing a Console warning does not publish an npm package or prove registry availability.
|
|
127
|
+
- Keep live-provider inference, browser/mobile QA and operating-system coverage distinct from package installation checks.
|
|
128
|
+
|
|
129
|
+
## Publishing an update
|
|
130
|
+
|
|
131
|
+
1. Make and review the change.
|
|
132
|
+
2. Update the `version` in `package.json` to a new semantic version. Use a patch release for compatible fixes, a minor release for compatible features, and a major release for breaking changes. Pre-1.0 compatibility expectations should still be documented explicitly.
|
|
133
|
+
3. Run `yarn install` to reconcile the lockfile if necessary, then repeat the checks above.
|
|
134
|
+
4. Commit and push the reviewed release source when authorized.
|
|
135
|
+
5. Publish, then verify the exact new version and the intended distribution tag.
|
|
136
|
+
|
|
137
|
+
Never overwrite an existing version. For a bad release, prepare a fixed version; consider npm deprecation according to the project's release policy rather than treating unpublish as rollback.
|
|
138
|
+
|
|
139
|
+
## Troubleshooting
|
|
140
|
+
|
|
141
|
+
| Symptom | What to check |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `401`, `ENEEDAUTH`, or invalid credentials | Repeat `yarn npm login --scope 1endpoint --publish`, then check `whoami`. Confirm the publishing registry and account. |
|
|
144
|
+
| `403` or scope/package permission denied | Confirm organization membership, package creation/publish rights, account security requirements and token restrictions if using token authentication. Login alone is insufficient. |
|
|
145
|
+
| Package not found before the first release | Expected for an unpublished package, but also possible with the wrong registry or inaccessible package. Verify scope ownership and registry configuration. |
|
|
146
|
+
| Version already published | Select a new version; an existing version cannot be replaced. |
|
|
147
|
+
| OTP invalid or expired | Use a fresh code from the publishing account and check the device clock. Do not weaken account security. |
|
|
148
|
+
| `prepack` fails | Fix the syntax/test failure and rerun the release checks. Do not bypass the release gate to publish. |
|
|
149
|
+
| Publish timed out or disconnected | Query the exact version in the registry before attempting another publish. |
|
|
150
|
+
| `npx` cannot find the release immediately | Check exact registry metadata, allow propagation time, and use the pinned version. Confirm you did not publish only under a prerelease tag. |
|
|
151
|
+
| CLI exits because Node is unsupported | Install a Node version satisfying `>=22.19.0` and retry. |
|
|
152
|
+
|
|
153
|
+
For CI automation, configure npm trusted publishing or appropriately restricted credentials using current npm and CI-provider documentation. Do not assume this local Yarn login procedure configures CI identity, provenance or release approvals.
|
package/docs/RELEASE.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Release and verification gate
|
|
2
|
+
|
|
3
|
+
For the complete maintainer procedure, follow the [npm publishing guide](PUBLISHING.md): account/scope permissions, login, release checks, publication, pinned npx verification, updates and troubleshooting.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
Initial package: `@1endpoint/coding-helper@0.1.0`, public npm access. Independent repository: `dothinh115/1endpoint-coding-helper`. First Git commit: `first push`. **Git push does not authorize npm publication.** Maintainer publishes after reviewing the handoff.
|
|
8
|
+
|
|
9
|
+
## Architecture and support plan
|
|
10
|
+
|
|
11
|
+
1. First release prioritizes native DeepSeek Harness and Claude Code CLI connection contracts, isolated homes, environment-only credentials, language-first EN/VI colored arrow menus, fetched public text-model choices, independent per-tier model mappings, prominent session-restart instructions, local diagnostics, automatic-backup reconfiguration and command-managed configuration removal/recovery.
|
|
12
|
+
2. Keep original tool settings untouched. Do not migrate plugins, sessions, permissions, hooks or credentials implicitly. Native coding tools remain owners of agent execution.
|
|
13
|
+
3. Extend tools through separate declarative adapters returning generated files, command/argv, child environment and safety hints. New adapters require isolated tests and actual installed-client contract checks.
|
|
14
|
+
4. Future features (automatic version-specific tier discovery, user-selected integration of existing project settings, remote credential validation, optional paid inference smoke) require separate opt-in and independent security tests. Do not represent local doctor checks as network authentication checks.
|
|
15
|
+
5. Broaden compatibility by testing pinned newer tool versions and operating systems before widening support claims. No automatic self-update, tool installation, permission bypass or runtime proxy is part of 0.1.0.
|
|
16
|
+
|
|
17
|
+
## Required local checks
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
yarn install --immutable
|
|
21
|
+
yarn check
|
|
22
|
+
yarn test
|
|
23
|
+
yarn pack --out /absolute/reviewed/output/coding-helper-0.1.0.tgz
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Check the tarball allowlist, entry executable bit, no secrets/dev artifacts, package version/help, local `yarn dlx` invocation from the packed tarball, and a setup/doctor cycle with an isolated home. Avoid running the unpublished package name against the registry as a test.
|
|
27
|
+
|
|
28
|
+
Configuration tests use fake keys and temporary isolated homes. Installed DSH checks parse real composed configuration and headless schema without a provider call. Installed Claude tests may use a loopback synthetic Messages server only; that proves client wire shape, not production gateway authentication or billing. Native Web schema has an upstream preset/tree-carrier diagnostic limitation separate from provider setup.
|
|
29
|
+
|
|
30
|
+
Console checks: focused coding-helper documentation regressions, scoped ESLint, typecheck, production build (including EN/VI message compilation), whitespace and vendor-byte integrity checks. Browser/mobile verification and production deployment remain separate boundaries.
|
|
31
|
+
|
|
32
|
+
## Before npm publication
|
|
33
|
+
|
|
34
|
+
- Confirm repository source, initial commit and remote branch match reviewed package files.
|
|
35
|
+
- Confirm no release blocker in test output or security review, and inspect tarball.
|
|
36
|
+
- Verify the npm account has publish rights for scope `@1endpoint` and satisfies npm 2FA/trusted-publishing requirements. Never copy npm credentials into repository or chat.
|
|
37
|
+
- Re-run the checks above from the committed source.
|
|
38
|
+
- Maintainer may publish with Yarn's npm publish command, public access, and the desired release tag. This task deliberately does not run it.
|
|
39
|
+
- After publication verify registry version, integrity, bin mapping and real `npx @1endpoint/coding-helper --version` from a fresh environment before announcing availability. The Console unpublished warning was removed separately at the user's request; that presentation change is not proof of publication. Commit/deploy Console changes through its normal pipeline when authorized.
|
|
40
|
+
|
|
41
|
+
## Verification record
|
|
42
|
+
|
|
43
|
+
Local verification on 2026-10-15: 52/52 tests pass with the explicit installed-Claude fixture enabled; no skips/failures. This includes DSH 0.2.1-alpha.3 native composition/headless schema and Claude Code 2.1.294 loopback Messages request/response (exact `/api/v1/v1/messages?beta=true`, raw dummy key becomes Bearer, selected model retained). The ordinary suite skips the opt-in installed-Claude fixture. Public anonymous catalog smoke returns 22 text models; this is not credential or paid-inference validation. Console has 7/7 focused regressions plus scoped lint/typecheck/production build passing.
|
|
44
|
+
|
|
45
|
+
Final Yarn tarball contains only LICENSE, README, release guide, package metadata and six source modules; entry executable bit is present. Local tarball `yarn dlx` version/help and setup → independent tier mapping → reconfigure/automatic backup → remove preview/confirmation pass. Source tests additionally cover language-first EN/VI, scrolling arrow menus, no y/n, explicit recovery, restart messaging, private writes, integrity, symlinks, fake launch environments and secret nonpersistence.
|
|
46
|
+
|
|
47
|
+
macOS local testing does not establish Windows/Linux E2E compatibility. Optional Fable selection is explicit based on operator-known installed-version support; automatic version-specific tier discovery remains future work. Actual paid gateway inference, browser/mobile QA, frontend deployment and npm publication are not performed. Native Web schema dumping retains its upstream preset diagnostic limitation. Same-user malicious filesystem races are not a supported security boundary; this is not an OS sandbox.
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1endpoint/coding-helper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Safe isolated 1endpoint setup for DeepSeek Harness and Claude Code CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"1endpoint-helper": "src/cli.mjs",
|
|
8
|
+
"coding-helper": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"docs"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.19.0"
|
|
18
|
+
},
|
|
19
|
+
"packageManager": "yarn@4.17.0",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"check": "node --check src/cli.mjs",
|
|
23
|
+
"prepack": "yarn check && yarn test"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/dothinh115/1endpoint-coding-helper.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://console.1endpoint.dev/docs/guides/coding-tools",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/dothinh115/1endpoint-coding-helper/issues"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"1endpoint",
|
|
39
|
+
"deepseek",
|
|
40
|
+
"harness",
|
|
41
|
+
"claude-code",
|
|
42
|
+
"cli",
|
|
43
|
+
"setup"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export const MODEL_VARIABLES = ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']
|
|
4
|
+
export const CLAUDE_ROLES = [{ id: 'haiku', label: 'Haiku' }, { id: 'sonnet', label: 'Sonnet' }, { id: 'opus', label: 'Opus' }]
|
|
5
|
+
export const CLAUDE_OPTIONAL_ROLES = [{ id: 'fable', label: 'Fable' }]
|
|
6
|
+
export function createClaudePlan({ stateDir, model, roleModels }) {
|
|
7
|
+
const directory = path.resolve(stateDir, 'claude')
|
|
8
|
+
const settings = { model, env: Object.fromEntries(MODEL_VARIABLES.map(name => [name, model])) }
|
|
9
|
+
if (roleModels) {
|
|
10
|
+
settings.model = roleModels.sonnet || model
|
|
11
|
+
settings.env.ANTHROPIC_MODEL = roleModels.sonnet || model
|
|
12
|
+
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = roleModels.haiku || model
|
|
13
|
+
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = roleModels.sonnet || model
|
|
14
|
+
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = roleModels.opus || model
|
|
15
|
+
settings.env.ANTHROPIC_DEFAULT_FABLE_MODEL = roleModels.fable || roleModels.opus || model
|
|
16
|
+
settings.env.CLAUDE_CODE_SUBAGENT_MODEL = roleModels.sonnet || model
|
|
17
|
+
}
|
|
18
|
+
settings.env.ANTHROPIC_BASE_URL = 'https://1endpoint.dev/api/v1'
|
|
19
|
+
return {
|
|
20
|
+
files: [{ path: path.join(directory, 'settings.json'), content: JSON.stringify(settings, null, 2) + '\n' }],
|
|
21
|
+
command: 'claude',
|
|
22
|
+
args: ['--setting-sources', 'user', '--settings', path.join(directory, 'settings.json')],
|
|
23
|
+
env: { CLAUDE_CONFIG_DIR: path.join(directory, 'home'), ...settings.env },
|
|
24
|
+
hints: ['Project/local settings, permissions and hooks are intentionally excluded. Managed policy still applies.', 'Normal tool permission and workspace trust prompts remain enabled.'],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function launchClaude({ stateDir, model, roleModels, args = [] }) {
|
|
28
|
+
// Do not allow forwarded flags to silently replace the routing/isolation contract.
|
|
29
|
+
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))) throw new Error('Tool arguments must be strings without NUL characters.')
|
|
30
|
+
const blocked = /^(?:--(?:settings|setting-sources|model|fallback-model|agent|agents|plugin-dir|bare|dangerously-skip-permissions|allow-dangerously-skip-permissions|permission-mode)(?:=|$)|-m)/
|
|
31
|
+
if (args.some(arg => blocked.test(arg))) throw new Error('Routing, agent and permission-bypass overrides are not supported by the isolated launcher.')
|
|
32
|
+
return { ...createClaudePlan({ stateDir, model, roleModels }), args: [...createClaudePlan({ stateDir, model, roleModels }).args, ...args] }
|
|
33
|
+
}
|
|
34
|
+
export function childEnvironment(env, plan, key) {
|
|
35
|
+
const result = { ...env }
|
|
36
|
+
for (const name of Object.keys(result)) {
|
|
37
|
+
if (/^(?:ANTHROPIC_|CLAUDE_|DSH_)/.test(name) || /^(?:NODE_OPTIONS|NODE_PATH|BASH_ENV|ENV|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_.*)$/.test(name)) delete result[name]
|
|
38
|
+
}
|
|
39
|
+
Object.assign(result, plan.env)
|
|
40
|
+
if (plan.command === 'claude') result.ANTHROPIC_AUTH_TOKEN = key
|
|
41
|
+
result.ONEENDPOINT_API_KEY = key
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const PROFILES = new Set(['web', 'headless', 'acp', 'sdk']);
|
|
4
|
+
const ROUTING_FLAGS = new Set([
|
|
5
|
+
'--patch', '--profile', '--from-default-profile', '--dump-default-config',
|
|
6
|
+
'--config', '--config-file', '--home', '--dsh-home',
|
|
7
|
+
'--provider', '--model', '--api-key', '--api-key-env', '--base-url', '--baseURL',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function nonemptyString(value, name) {
|
|
11
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) {
|
|
12
|
+
throw new TypeError(`${name} must be a non-empty string without NUL characters`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Build an isolated, declarative DSH configuration; never reads or writes secrets. */
|
|
17
|
+
export function createDshPlan({ stateDir, model } = {}) {
|
|
18
|
+
return launchDsh({ stateDir, model });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Return files and a launch specification; the caller materializes and executes it. */
|
|
22
|
+
export function launchDsh({ stateDir, model, args = [], profile = 'web' } = {}) {
|
|
23
|
+
nonemptyString(stateDir, 'stateDir');
|
|
24
|
+
nonemptyString(model, 'model');
|
|
25
|
+
if (!PROFILES.has(profile)) {
|
|
26
|
+
throw new TypeError(`Unsupported DSH profile: ${String(profile)}; use web, headless, acp, or sdk`);
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string' || arg.includes('\0'))) {
|
|
29
|
+
throw new TypeError('args must be an array of strings without NUL characters');
|
|
30
|
+
}
|
|
31
|
+
for (const arg of args) {
|
|
32
|
+
// Commander permits unambiguous long-option abbreviations; reject those too.
|
|
33
|
+
const flag = arg.split('=', 1)[0];
|
|
34
|
+
if (flag.startsWith('--') && flag.length > 2
|
|
35
|
+
&& [...ROUTING_FLAGS].some((blocked) => blocked.startsWith(flag))) {
|
|
36
|
+
throw new TypeError(`DSH routing override argument is not allowed: ${flag}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const directory = resolve(stateDir, 'dsh');
|
|
40
|
+
const home = join(directory, 'home');
|
|
41
|
+
const overlay = join(directory, '1endpoint.patch.yml');
|
|
42
|
+
const patches = [
|
|
43
|
+
{
|
|
44
|
+
id: 'llm-pi-ai',
|
|
45
|
+
name: '@deepseek-ai/dsh-llm-pi-ai',
|
|
46
|
+
config: {
|
|
47
|
+
providers: {
|
|
48
|
+
'1endpoint': {
|
|
49
|
+
displayName: '1endpoint',
|
|
50
|
+
api: 'openai-completions',
|
|
51
|
+
baseURL: 'https://1endpoint.dev/api/v1',
|
|
52
|
+
apiKeyEnv: 'ONEENDPOINT_API_KEY',
|
|
53
|
+
models: [{ id: model }],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'agent-default-model',
|
|
60
|
+
name: '@deepseek-ai/dsh-agent-default-model',
|
|
61
|
+
config: { provider: '1endpoint', model },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
return {
|
|
65
|
+
files: [{ path: overlay, content: `${JSON.stringify(patches, null, 2)}\n` }],
|
|
66
|
+
command: 'dsh',
|
|
67
|
+
args: ['--profile', profile, '--patch', overlay, ...args],
|
|
68
|
+
env: { DSH_HOME: home },
|
|
69
|
+
hints: [
|
|
70
|
+
'Set ONEENDPOINT_API_KEY in the child process environment; the key is never stored in the overlay.',
|
|
71
|
+
'DSH runs in an isolated home: existing user configuration is not read or merged. Patch config objects replace the entire entry config.',
|
|
72
|
+
'The sdk-minimal profile is unsupported because it lacks the provider and default-model plugin entries.',
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/catalog.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const CATALOG_URL = 'https://1endpoint.dev/api/v1/models'
|
|
2
|
+
const MAX_BYTES = 2 * 1024 * 1024
|
|
3
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,199}$/
|
|
4
|
+
export function decodeModels(payload) {
|
|
5
|
+
if (!payload || !Array.isArray(payload.data)) throw new Error('Model catalog has an invalid shape.')
|
|
6
|
+
const models = new Map()
|
|
7
|
+
for (const item of payload.data) {
|
|
8
|
+
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || !SAFE_ID.test(item.id)) continue
|
|
9
|
+
if (item.type && item.type !== 'text') continue
|
|
10
|
+
if (item.modelType && item.modelType !== 'text') continue
|
|
11
|
+
const name = typeof item.name === 'string' ? item.name : typeof item.display_name === 'string' ? item.display_name : item.id
|
|
12
|
+
models.set(item.id, { id: item.id, name: name.replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 200) })
|
|
13
|
+
}
|
|
14
|
+
if (!models.size) throw new Error('No compatible text models were returned by the catalog.')
|
|
15
|
+
return [...models.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
|
|
16
|
+
}
|
|
17
|
+
export async function fetchModels({ signal, fetchImpl = globalThis.fetch } = {}) {
|
|
18
|
+
const timeout = AbortSignal.timeout(10000)
|
|
19
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
20
|
+
let response
|
|
21
|
+
try {
|
|
22
|
+
// Public catalog only: never transmit user keys, cookies or inherited auth headers.
|
|
23
|
+
response = await fetchImpl(CATALOG_URL, { signal: combined, redirect: 'error', headers: { accept: 'application/json' }, credentials: 'omit' })
|
|
24
|
+
if (!response.ok) throw new Error('catalog_status')
|
|
25
|
+
if (!/application\/json/i.test(response.headers.get('content-type') || '')) throw new Error('catalog_type')
|
|
26
|
+
if (Number(response.headers.get('content-length')) > MAX_BYTES) throw new Error('catalog_size')
|
|
27
|
+
if (!response.body) throw new Error('catalog_empty')
|
|
28
|
+
const reader = response.body.getReader()
|
|
29
|
+
const chunks = []
|
|
30
|
+
let size = 0
|
|
31
|
+
const aborted = () => { void reader.cancel().catch(() => {}) }
|
|
32
|
+
combined.addEventListener('abort', aborted, { once: true })
|
|
33
|
+
try {
|
|
34
|
+
while (true) {
|
|
35
|
+
combined.throwIfAborted()
|
|
36
|
+
const { done, value } = await reader.read()
|
|
37
|
+
if (done) break
|
|
38
|
+
size += value.byteLength
|
|
39
|
+
if (size > MAX_BYTES) throw new Error('catalog_size')
|
|
40
|
+
chunks.push(value)
|
|
41
|
+
}
|
|
42
|
+
combined.throwIfAborted()
|
|
43
|
+
return decodeModels(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
44
|
+
} finally {
|
|
45
|
+
combined.removeEventListener('abort', aborted)
|
|
46
|
+
void reader.cancel().catch(() => {})
|
|
47
|
+
reader.releaseLock()
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {})
|
|
51
|
+
throw new Error('Could not load the public 1endpoint model catalog. Retry or explicitly choose manual model ID entry.')
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { wizard, colorize } from './ui.mjs'
|
|
7
|
+
import { setup, reconfigure, remove, backup, loadState, stateRoot, validateTool, assertSafePath } from './state.mjs'
|
|
8
|
+
import { launchClaude, childEnvironment } from './adapters/claude.mjs'
|
|
9
|
+
import { launchDsh } from './adapters/dsh.mjs'
|
|
10
|
+
|
|
11
|
+
const HELP = `@1endpoint/coding-helper — isolated 1endpoint coding-tool setup
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
coding-helper setup --tool dsh|claude --model PUBLIC_MODEL [--dry-run] [--update]
|
|
15
|
+
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 doctor --tool dsh|claude
|
|
18
|
+
coding-helper run --tool dsh|claude [--profile web|headless|acp|sdk] -- [tool args]
|
|
19
|
+
coding-helper backup --tool dsh|claude
|
|
20
|
+
coding-helper --help | --version
|
|
21
|
+
|
|
22
|
+
No arguments starts English/Tiếng Việt selection, then colored arrow-key action menus.
|
|
23
|
+
Wizard fetches public 1endpoint text models for independent tool/tier selections.
|
|
24
|
+
Claude setup/reconfigure accepts --haiku-model ID --sonnet-model ID --opus-model ID
|
|
25
|
+
and optional --fable-model ID for supporting versions. --model is the main/default fallback.
|
|
26
|
+
Node.js >=22.19 is required.
|
|
27
|
+
API key: export ONEENDPOINT_API_KEY in your shell; never pass it as an argument.
|
|
28
|
+
State: ONEENDPOINT_HELPER_HOME or ~/.1endpoint/coding-helper (isolated tool homes).
|
|
29
|
+
No existing DSH/Claude settings are edited. Setup/doctor make no API requests.
|
|
30
|
+
Claude launch excludes project/local settings, hooks and permissions; managed policy applies.
|
|
31
|
+
Run is explicit opt-in to tool startup and potentially billed requests. Normal permission prompts remain.
|
|
32
|
+
--update changes an existing helper-owned model only after hash validation; back up first.
|
|
33
|
+
`
|
|
34
|
+
function parse(argv) {
|
|
35
|
+
const args = [...argv]
|
|
36
|
+
const command = args.shift()
|
|
37
|
+
const options = {}, forwarded = []
|
|
38
|
+
while (args.length) {
|
|
39
|
+
const arg = args.shift()
|
|
40
|
+
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 }
|
|
42
|
+
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
|
+
if (options[arg] !== undefined || !args.length || args[0].startsWith('-')) throw new Error('Missing or duplicate option value.')
|
|
44
|
+
options[arg] = args.shift()
|
|
45
|
+
}
|
|
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'] }
|
|
47
|
+
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
|
+
validateTool(options['--tool'])
|
|
49
|
+
if (options['--tool'] !== 'claude' && ['haiku', 'sonnet', 'opus', 'fable'].some(role => options[`--${role}-model`])) throw new Error('Tier model flags are only supported for Claude.')
|
|
50
|
+
if (options['--profile'] && options['--tool'] !== 'dsh') throw new Error('--profile is only supported for DSH.')
|
|
51
|
+
if (options['--profile'] && !['web', 'headless', 'acp', 'sdk'].includes(options['--profile'])) throw new Error('Choose --profile web, headless, acp or sdk.')
|
|
52
|
+
return { command, options, forwarded }
|
|
53
|
+
}
|
|
54
|
+
async function executable(command, env) {
|
|
55
|
+
const extensions = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']
|
|
56
|
+
for (const directory of (env.PATH || '').split(path.delimiter)) {
|
|
57
|
+
if (!directory) continue
|
|
58
|
+
for (const suffix of extensions) {
|
|
59
|
+
const file = path.resolve(directory, command + suffix)
|
|
60
|
+
try { const stat = await fs.stat(file); if (stat.isFile()) { await fs.access(file, process.platform === 'win32' ? 0 : 1); return file } } catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
async function main(argv, context = {}) {
|
|
66
|
+
if (!argv.length) return wizard({ dispatch: main })
|
|
67
|
+
if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { console.log(HELP); return }
|
|
68
|
+
if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) {
|
|
69
|
+
const metadata = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
|
70
|
+
console.log(metadata.version); return
|
|
71
|
+
}
|
|
72
|
+
const { command, options, forwarded } = parse(argv)
|
|
73
|
+
const t = (en, vi) => context.language === 'vi' ? vi : en
|
|
74
|
+
const tool = options['--tool'], stateDir = stateRoot()
|
|
75
|
+
if (command === 'remove') {
|
|
76
|
+
const result = await remove({ stateDir, tool, dryRun: options['--dry-run'], yes: options['--yes'], force: options['--force'] })
|
|
77
|
+
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
|
+
for (const file of result.files) console.log(` ${file}`)
|
|
79
|
+
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.'))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (command === 'setup' || command === 'reconfigure') {
|
|
84
|
+
const selectedRoles = Object.fromEntries(['haiku', 'sonnet', 'opus', 'fable'].filter(role => options[`--${role}-model`]).map(role => [role, options[`--${role}-model`]]))
|
|
85
|
+
const result = await (command === 'reconfigure' ? reconfigure : setup)({ stateDir, tool, model: options['--model'] || selectedRoles.sonnet, roles: Object.keys(selectedRoles).length ? selectedRoles : undefined, dryRun: options['--dry-run'], update: options['--update'] })
|
|
86
|
+
console.log(`${result.dryRun ? t('Preview only', 'Chỉ xem trước') : result.changed ? t('Configured', 'Đã cấu hình') : t('Already configured', 'Cấu hình không thay đổi')}: ${tool}`)
|
|
87
|
+
for (const file of result.files) console.log(` ${file.path}`)
|
|
88
|
+
if (context.language !== 'vi') for (const hint of result.hints || []) console.log(hint)
|
|
89
|
+
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.'))
|
|
91
|
+
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
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (command === 'backup') { console.log('Secret-free configuration backup: ' + await backup({ stateDir, tool })); return }
|
|
95
|
+
const { state, plan } = await loadState(stateDir, tool)
|
|
96
|
+
const bin = await executable(plan.command, process.env)
|
|
97
|
+
if (command === 'doctor') {
|
|
98
|
+
console.log(`${t('Configuration integrity: OK', 'Tính toàn vẹn cấu hình: OK')} (${tool})`)
|
|
99
|
+
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ó')}`)
|
|
101
|
+
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
|
+
if (context.language !== 'vi') for (const hint of plan.hints || []) console.log(hint)
|
|
103
|
+
if (!bin || !process.env.ONEENDPOINT_API_KEY) process.exitCode = 1
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
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.')
|
|
109
|
+
let launch
|
|
110
|
+
try { launch = (tool === 'dsh' ? launchDsh : launchClaude)({ stateDir, model: state.model, roleModels: state.roles, args: forwarded, profile: options['--profile'] || 'web' }) }
|
|
111
|
+
catch { throw new Error('Unsupported tool arguments: routing, agent/plugin and permission-bypass overrides are not allowed.') }
|
|
112
|
+
await assertSafePath(launch.env.DSH_HOME || launch.env.CLAUDE_CONFIG_DIR)
|
|
113
|
+
await fs.mkdir(launch.env.DSH_HOME || launch.env.CLAUDE_CONFIG_DIR, { recursive: true, mode: 0o700 })
|
|
114
|
+
const child = spawn(bin, launch.args, { stdio: 'inherit', env: childEnvironment(process.env, launch, key), shell: false })
|
|
115
|
+
const signals = ['SIGINT', 'SIGTERM']
|
|
116
|
+
const handlers = signals.map(signal => { const handler = () => child.kill(signal); process.on(signal, handler); return [signal, handler] })
|
|
117
|
+
await new Promise((resolve, reject) => {
|
|
118
|
+
child.once('error', () => reject(new Error('Tool launch failed. Check the executable and local runtime.')))
|
|
119
|
+
child.once('close', (code, signal) => { process.exitCode = code ?? (signal === 'SIGINT' ? 130 : 143); resolve() })
|
|
120
|
+
}).finally(() => { for (const [signal, handler] of handlers) process.off(signal, handler) })
|
|
121
|
+
}
|
|
122
|
+
if (process.argv[1] && await fs.realpath(path.resolve(process.argv[1])).catch(() => '') === fileURLToPath(import.meta.url)) {
|
|
123
|
+
main(process.argv.slice(2)).catch(error => {
|
|
124
|
+
// Never serialize raw parser, filesystem or child error details: these can contain private bytes.
|
|
125
|
+
const safe = error.code ? 'Local filesystem operation failed. Check helper state permissions and paths.' : error.message
|
|
126
|
+
console.error(colorize('coding-helper: ' + safe, 'error', { output: process.stderr }))
|
|
127
|
+
process.exitCode = 1
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
export { main, parse }
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { createClaudePlan } from './adapters/claude.mjs'
|
|
6
|
+
import { createDshPlan } from './adapters/dsh.mjs'
|
|
7
|
+
|
|
8
|
+
export const tools = ['dsh', 'claude']
|
|
9
|
+
export function stateRoot(env = process.env) {
|
|
10
|
+
return path.resolve(env.ONEENDPOINT_HELPER_HOME || path.join(os.homedir(), '.1endpoint', 'coding-helper'))
|
|
11
|
+
}
|
|
12
|
+
export function validateTool(tool) {
|
|
13
|
+
if (!tools.includes(tool)) throw new Error('Choose --tool dsh or --tool claude.')
|
|
14
|
+
return tool
|
|
15
|
+
}
|
|
16
|
+
export function validateModel(model) {
|
|
17
|
+
if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,199}$/.test(model)) throw new Error('Provide a public model ID using --model (letters, digits, dot, slash, colon, plus, underscore or hyphen).')
|
|
18
|
+
return model
|
|
19
|
+
}
|
|
20
|
+
export function planFor({ stateDir, tool, model, roles }) {
|
|
21
|
+
validateTool(tool); validateModel(model)
|
|
22
|
+
if (roles !== undefined) {
|
|
23
|
+
if (tool !== 'claude' || !roles || typeof roles !== 'object' || Array.isArray(roles) || Object.keys(roles).some(key => !['haiku', 'sonnet', 'opus', 'fable'].includes(key))) throw new Error('Invalid Claude model role mapping.')
|
|
24
|
+
for (const value of Object.values(roles)) validateModel(value)
|
|
25
|
+
}
|
|
26
|
+
return (tool === 'dsh' ? createDshPlan : createClaudePlan)({ stateDir, model, roleModels: roles })
|
|
27
|
+
}
|
|
28
|
+
export const digest = data => createHash('sha256').update(data).digest('hex')
|
|
29
|
+
|
|
30
|
+
// Reject symlinks in every existing ancestor, including an overridden state root.
|
|
31
|
+
export async function assertSafePath(target) {
|
|
32
|
+
const absolute = path.resolve(target)
|
|
33
|
+
const parts = absolute.split(path.sep).filter(Boolean)
|
|
34
|
+
let current = path.parse(absolute).root
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
current = path.join(current, part)
|
|
37
|
+
try {
|
|
38
|
+
const stat = await fs.lstat(current)
|
|
39
|
+
if (stat.isSymbolicLink()) throw new Error('Refusing a symbolic link in the helper state path.')
|
|
40
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function optionalRead(file) {
|
|
44
|
+
try { return await fs.readFile(file, 'utf8') } catch (error) { if (error.code === 'ENOENT') return null; throw error }
|
|
45
|
+
}
|
|
46
|
+
export async function loadState(stateDir, tool) {
|
|
47
|
+
validateTool(tool)
|
|
48
|
+
await assertSafePath(stateDir)
|
|
49
|
+
const file = path.join(stateDir, `${tool}.json`)
|
|
50
|
+
await assertSafePath(file)
|
|
51
|
+
const raw = await optionalRead(file)
|
|
52
|
+
if (raw === null) throw new Error('Tool is not configured. Run setup first.')
|
|
53
|
+
let state
|
|
54
|
+
try { state = JSON.parse(raw) } catch { throw new Error('Invalid helper manifest; refusing to change state.') }
|
|
55
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) || !['files,model,tool', 'files,model,roles,tool'].includes(Object.keys(state).sort().join(',')) || state.tool !== tool || typeof state.model !== 'string' || !Array.isArray(state.files)) throw new Error('Invalid helper manifest.')
|
|
56
|
+
const plan = planFor({ stateDir, tool, model: state.model, roles: state.roles })
|
|
57
|
+
if (JSON.stringify(state.files) !== JSON.stringify(plan.files.map(file => ({ path: file.path, hash: digest(file.content) })))) throw new Error('Helper manifest does not match the generated plan.')
|
|
58
|
+
for (const file of plan.files) {
|
|
59
|
+
await assertSafePath(file.path)
|
|
60
|
+
const content = await optionalRead(file.path)
|
|
61
|
+
if (content === null || digest(content) !== digest(file.content)) throw new Error('Helper-owned configuration was modified. Review it before setup; refusing to overwrite or launch it.')
|
|
62
|
+
}
|
|
63
|
+
return { state, plan }
|
|
64
|
+
}
|
|
65
|
+
async function atomicWrite(file, content) {
|
|
66
|
+
await assertSafePath(file)
|
|
67
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
68
|
+
const temp = `${file}.${randomUUID()}.tmp`
|
|
69
|
+
try {
|
|
70
|
+
await fs.writeFile(temp, content, { flag: 'wx', mode: 0o600 })
|
|
71
|
+
await fs.rename(temp, file)
|
|
72
|
+
await fs.chmod(file, 0o600)
|
|
73
|
+
} finally { await fs.unlink(temp).catch(error => { if (error.code !== 'ENOENT') throw error }) }
|
|
74
|
+
}
|
|
75
|
+
export async function setup({ stateDir, tool, model, roles, dryRun = false, update = false, autoBackup = false }) {
|
|
76
|
+
const plan = planFor({ stateDir, tool, model, roles })
|
|
77
|
+
await assertSafePath(stateDir)
|
|
78
|
+
const manifest = path.join(stateDir, `${tool}.json`)
|
|
79
|
+
await assertSafePath(manifest)
|
|
80
|
+
const existing = await optionalRead(manifest)
|
|
81
|
+
if (existing !== null) {
|
|
82
|
+
const loaded = await loadState(stateDir, tool)
|
|
83
|
+
if ((loaded.state.model !== model || JSON.stringify(loaded.state.roles) !== JSON.stringify(roles)) && !update) throw new Error('A different model is configured. Use --update after reviewing the change.')
|
|
84
|
+
} else {
|
|
85
|
+
for (const file of plan.files) {
|
|
86
|
+
await assertSafePath(file.path)
|
|
87
|
+
if (await optionalRead(file.path) !== null) throw new Error('Unmanaged file exists in helper state; refusing to overwrite it.')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (dryRun) return { ...plan, changed: false, dryRun: true }
|
|
91
|
+
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 })
|
|
92
|
+
const lock = path.join(stateDir, '.setup.lock')
|
|
93
|
+
let handle
|
|
94
|
+
try { handle = await fs.open(lock, 'wx', 0o600) } catch (error) { if (error.code === 'EEXIST') throw new Error('Another setup may be active. Review the helper lock before retrying.'); throw error }
|
|
95
|
+
try {
|
|
96
|
+
// Repeat validation after acquiring the exclusive writer lock.
|
|
97
|
+
if (await optionalRead(manifest) !== existing) throw new Error('State changed during setup; retry.')
|
|
98
|
+
if (existing !== null) await loadState(stateDir, tool)
|
|
99
|
+
const prior = []
|
|
100
|
+
for (const file of plan.files) {
|
|
101
|
+
await assertSafePath(file.path)
|
|
102
|
+
const content = await optionalRead(file.path)
|
|
103
|
+
if (existing === null && content !== null) throw new Error('Unmanaged file appeared during setup.')
|
|
104
|
+
prior.push({ path: file.path, content })
|
|
105
|
+
}
|
|
106
|
+
if (existing !== null && JSON.parse(existing).model === model && JSON.stringify(JSON.parse(existing).roles) === JSON.stringify(roles)) return { ...plan, changed: false }
|
|
107
|
+
if (existing !== null && autoBackup) {
|
|
108
|
+
const previous = await loadState(stateDir, tool)
|
|
109
|
+
await atomicWrite(path.join(stateDir, 'backups', `${tool}-${randomUUID()}.json`), JSON.stringify({ model: previous.state.model, ...(previous.state.roles ? { roles: previous.state.roles } : {}), tool, files: previous.plan.files }, null, 2) + '\n')
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
for (const file of plan.files) await atomicWrite(file.path, file.content)
|
|
113
|
+
const state = { tool, model, ...(roles ? { roles } : {}), files: plan.files.map(file => ({ path: file.path, hash: digest(file.content) })) }
|
|
114
|
+
await atomicWrite(manifest, JSON.stringify(state, null, 2) + '\n')
|
|
115
|
+
} catch (error) {
|
|
116
|
+
for (const file of prior.reverse()) {
|
|
117
|
+
if (file.content === null) { await assertSafePath(file.path); await fs.unlink(file.path).catch(e => { if (e.code !== 'ENOENT') throw e }) }
|
|
118
|
+
else await atomicWrite(file.path, file.content)
|
|
119
|
+
}
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
return { ...plan, changed: true }
|
|
123
|
+
} finally { await handle.close(); await fs.unlink(lock) }
|
|
124
|
+
}
|
|
125
|
+
export async function reconfigure(options) {
|
|
126
|
+
return setup({ ...options, update: true, autoBackup: true })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function remove({ stateDir, tool, dryRun = false, yes = false, force = false }) {
|
|
130
|
+
validateTool(tool)
|
|
131
|
+
if (!yes && !dryRun) throw new Error('Removal requires --yes. Preview with --dry-run first.')
|
|
132
|
+
// Never trust paths from a possibly corrupt manifest, even in recovery mode.
|
|
133
|
+
const files = [...planFor({ stateDir, tool, model: 'removal-placeholder' }).files.map(file => file.path), path.resolve(stateDir, `${tool}.json`)]
|
|
134
|
+
async function inspect() {
|
|
135
|
+
await assertSafePath(stateDir)
|
|
136
|
+
const present = []
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
await assertSafePath(file)
|
|
139
|
+
try {
|
|
140
|
+
const stat = await fs.lstat(file)
|
|
141
|
+
if (!stat.isFile()) throw new Error('Refusing a non-file removal target.')
|
|
142
|
+
present.push(file)
|
|
143
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
144
|
+
}
|
|
145
|
+
if (present.length && !force) await loadState(stateDir, tool)
|
|
146
|
+
return present
|
|
147
|
+
}
|
|
148
|
+
let present = await inspect()
|
|
149
|
+
if (dryRun || !present.length) return { files: present, changed: false, dryRun, force }
|
|
150
|
+
const lock = path.join(stateDir, '.setup.lock')
|
|
151
|
+
let handle
|
|
152
|
+
try { handle = await fs.open(lock, 'wx', 0o600) } catch (error) { if (error.code === 'EEXIST') throw new Error('Another setup may be active. Review the helper lock before retrying.'); throw error }
|
|
153
|
+
try {
|
|
154
|
+
present = await inspect()
|
|
155
|
+
for (const file of present) { await assertSafePath(file); await fs.unlink(file) }
|
|
156
|
+
return { files: present, changed: present.length > 0, dryRun: false, force }
|
|
157
|
+
} finally { await handle.close(); await fs.unlink(lock) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function backup({ stateDir, tool }) {
|
|
161
|
+
// Validate before creating anything, then serialize the snapshot with setup.
|
|
162
|
+
await loadState(stateDir, tool)
|
|
163
|
+
const lock = path.join(stateDir, '.setup.lock')
|
|
164
|
+
let handle
|
|
165
|
+
try { handle = await fs.open(lock, 'wx', 0o600) } catch (error) { if (error.code === 'EEXIST') throw new Error('Another setup may be active. Review the helper lock before retrying.'); throw error }
|
|
166
|
+
try {
|
|
167
|
+
const { state, plan } = await loadState(stateDir, tool)
|
|
168
|
+
const file = path.join(stateDir, 'backups', `${tool}-${randomUUID()}.json`)
|
|
169
|
+
await atomicWrite(file, JSON.stringify({ model: state.model, ...(state.roles ? { roles: state.roles } : {}), tool, files: plan.files }, null, 2) + '\n')
|
|
170
|
+
return file
|
|
171
|
+
} finally { await handle.close(); await fs.unlink(lock) }
|
|
172
|
+
}
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
|
+
import { fetchModels } from './catalog.mjs'
|
|
3
|
+
import * as claudeAdapter from './adapters/claude.mjs'
|
|
4
|
+
|
|
5
|
+
const palette = { heading: 36, info: 34, success: 32, warning: 33, error: 31, muted: 90, selected: 35 }
|
|
6
|
+
|
|
7
|
+
/** Semantic colors; NO_COLOR wins and FORCE_COLOR=0 disables colors. */
|
|
8
|
+
export function colorize(text, kind, { output = process.stdout, env = process.env } = {}) {
|
|
9
|
+
const force = env.FORCE_COLOR
|
|
10
|
+
const enabled = !Object.hasOwn(env, 'NO_COLOR') && (force !== undefined ? force !== '0' : Boolean(output.isTTY))
|
|
11
|
+
const code = palette[kind]
|
|
12
|
+
return enabled && code ? `\u001b[${code}m${text}\u001b[0m` : String(text)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function uiError(cancelled = false) {
|
|
16
|
+
const error = new Error(cancelled ? 'Selection cancelled; no action taken.' : 'Terminal interaction failed; no action taken.')
|
|
17
|
+
error.code = cancelled ? 'UI_CANCELLED' : 'UI_TERMINAL_ERROR'
|
|
18
|
+
return error
|
|
19
|
+
}
|
|
20
|
+
const clean = text => String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
|
|
21
|
+
|
|
22
|
+
/** Arrow-key menu. No typed answers are accepted. */
|
|
23
|
+
export async function select({ message, choices, input = process.stdin, output = process.stdout, language = 'en' }) {
|
|
24
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') {
|
|
25
|
+
throw new Error('Non-interactive setup needs setup --tool dsh|claude --model PUBLIC_MODEL.')
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(choices) || !choices.length || choices.some(choice => !choice || typeof choice.label !== 'string')) {
|
|
28
|
+
throw new Error('Selection requires labeled choices.')
|
|
29
|
+
}
|
|
30
|
+
const wasRaw = Boolean(input.isRaw)
|
|
31
|
+
const wasPaused = typeof input.isPaused === 'function' && input.isPaused()
|
|
32
|
+
let index = 0, rendered = false, buffer = '', timer, settled = false
|
|
33
|
+
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('')
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const cleanup = () => {
|
|
43
|
+
clearTimeout(timer)
|
|
44
|
+
input.off('data', onData)
|
|
45
|
+
input.off('error', onFailure)
|
|
46
|
+
input.off('end', onFailure)
|
|
47
|
+
input.off('close', onFailure)
|
|
48
|
+
output.off('error', onFailure)
|
|
49
|
+
try { input.setRawMode(wasRaw) } catch {}
|
|
50
|
+
try { if (wasPaused) input.pause() } catch {}
|
|
51
|
+
try { output.write('\u001b[?25h') } catch {}
|
|
52
|
+
}
|
|
53
|
+
const finish = (error, value) => {
|
|
54
|
+
if (settled) return
|
|
55
|
+
settled = true
|
|
56
|
+
cleanup()
|
|
57
|
+
if (error) reject(error)
|
|
58
|
+
else resolve(value)
|
|
59
|
+
}
|
|
60
|
+
function onFailure() { finish(uiError()) }
|
|
61
|
+
function draw() {
|
|
62
|
+
if (rendered) output.write(`\u001b[${lines}A\r`)
|
|
63
|
+
output.write('\u001b[2K' + colorize(fit(message), 'heading', { output }) + '\n')
|
|
64
|
+
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
|
+
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')
|
|
72
|
+
rendered = true
|
|
73
|
+
}
|
|
74
|
+
function onData(chunk) {
|
|
75
|
+
try {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
buffer += chunk.toString('utf8')
|
|
78
|
+
while (buffer && !settled) {
|
|
79
|
+
if (buffer[0] === '\u0003') return finish(uiError(true))
|
|
80
|
+
if (buffer[0] === '\r' || buffer[0] === '\n') return finish(null, choices[index].value)
|
|
81
|
+
if (buffer[0] === '\u001b') {
|
|
82
|
+
if (buffer.startsWith('\u001b[A') || buffer.startsWith('\u001bOA')) {
|
|
83
|
+
index = (index + choices.length - 1) % choices.length
|
|
84
|
+
buffer = buffer.slice(3); draw(); continue
|
|
85
|
+
}
|
|
86
|
+
if (buffer.startsWith('\u001b[B') || buffer.startsWith('\u001bOB')) {
|
|
87
|
+
index = (index + 1) % choices.length
|
|
88
|
+
buffer = buffer.slice(3); draw(); continue
|
|
89
|
+
}
|
|
90
|
+
if (['\u001b', '\u001b[', '\u001bO'].includes(buffer)) {
|
|
91
|
+
timer = setTimeout(() => finish(uiError(true)), 35)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
// Ignore other terminal navigation sequences (e.g. left/right).
|
|
95
|
+
const sequence = buffer.match(/^\u001b(?:\[[0-9;]*[A-Za-z~]|O[A-Za-z])/)
|
|
96
|
+
if (sequence) { buffer = buffer.slice(sequence[0].length); continue }
|
|
97
|
+
return finish(uiError(true))
|
|
98
|
+
}
|
|
99
|
+
buffer = buffer.slice(1) // Free text, including y/n, cannot choose anything.
|
|
100
|
+
}
|
|
101
|
+
} catch { finish(uiError()) }
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
input.on('data', onData)
|
|
105
|
+
input.on('error', onFailure)
|
|
106
|
+
input.on('end', onFailure)
|
|
107
|
+
input.on('close', onFailure)
|
|
108
|
+
output.on('error', onFailure)
|
|
109
|
+
input.setRawMode(true)
|
|
110
|
+
output.write('\u001b[?25l')
|
|
111
|
+
draw()
|
|
112
|
+
input.resume()
|
|
113
|
+
} catch { finish(uiError()) }
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function modelQuestion(input, output, language = 'en') {
|
|
118
|
+
const controller = new AbortController()
|
|
119
|
+
const terminal = createInterface({ input, output, terminal: true })
|
|
120
|
+
const abort = () => controller.abort()
|
|
121
|
+
input.on('error', abort)
|
|
122
|
+
input.on('end', abort)
|
|
123
|
+
input.on('close', abort)
|
|
124
|
+
terminal.on('SIGINT', abort)
|
|
125
|
+
try {
|
|
126
|
+
const model = (await terminal.question(colorize(language === 'vi' ? 'Mã mô hình công khai: ' : 'Public model ID: ', 'heading', { output }), { signal: controller.signal })).trim()
|
|
127
|
+
if (!model || /[\x00-\x1f\x7f]/.test(model)) throw new Error(language === 'vi' ? 'Nhập mã mô hình công khai hợp lệ.' : 'Enter a valid public model ID.')
|
|
128
|
+
return model
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (controller.signal.aborted) throw uiError(true)
|
|
131
|
+
throw error
|
|
132
|
+
} finally {
|
|
133
|
+
input.off('error', abort)
|
|
134
|
+
input.off('end', abort)
|
|
135
|
+
input.off('close', abort)
|
|
136
|
+
terminal.off('SIGINT', abort)
|
|
137
|
+
terminal.close()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 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 }) {
|
|
143
|
+
if (typeof dispatch !== 'function') throw new Error('Wizard requires a CLI dispatcher.')
|
|
144
|
+
let language = 'en'
|
|
145
|
+
const t = (en, vi) => language === 'vi' ? vi : en
|
|
146
|
+
const choose = (message, choices) => select({ message, choices, input, output, language })
|
|
147
|
+
const confirm = message => choose(message, [
|
|
148
|
+
{ label: t('Cancel', 'Hủy'), value: false, description: t('No changes', 'Không thay đổi') },
|
|
149
|
+
{ label: t('Confirm', 'Xác nhận'), value: true }
|
|
150
|
+
])
|
|
151
|
+
try {
|
|
152
|
+
language = await choose('Language / Ngôn ngữ', [
|
|
153
|
+
{ label: 'English', value: 'en' }, { label: 'Tiếng Việt', value: 'vi' }
|
|
154
|
+
])
|
|
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'), [
|
|
156
|
+
{ label: t('Setup', 'Cấu hình'), value: 'setup' },
|
|
157
|
+
{ label: t('Reconfigure', 'Cấu hình lại'), value: 'reconfigure' },
|
|
158
|
+
{ label: t('Remove', 'Xóa cấu hình'), value: 'remove' },
|
|
159
|
+
{ label: t('Doctor', 'Kiểm tra'), value: 'doctor', description: t('Local checks; no network requests', 'Kiểm tra cục bộ; không gửi yêu cầu mạng') },
|
|
160
|
+
{ label: t('Run', 'Chạy'), value: 'run', description: t('Starts the tool; may send billed requests', 'Khởi chạy công cụ; có thể phát sinh phí') },
|
|
161
|
+
{ label: t('Exit', 'Thoát'), value: 'exit' }
|
|
162
|
+
])
|
|
163
|
+
if (action === 'exit') return
|
|
164
|
+
const tool = await choose(t('Choose coding tool', 'Chọn công cụ lập trình'), [
|
|
165
|
+
{ label: 'DSH', value: 'dsh' }, { label: 'Claude Code', value: 'claude' }
|
|
166
|
+
])
|
|
167
|
+
const argv = [action, '--tool', tool]
|
|
168
|
+
if (action === 'setup' || action === 'reconfigure') {
|
|
169
|
+
let models, manual = false
|
|
170
|
+
while (!models && !manual) {
|
|
171
|
+
output.write(colorize(t('Loading public 1endpoint model catalog (no API key)...\n', 'Đang tải danh sách mô hình công khai từ 1endpoint (không dùng khóa API)...\n'), 'info', { output }))
|
|
172
|
+
try {
|
|
173
|
+
models = await loadModels()
|
|
174
|
+
if (!Array.isArray(models) || !models.length) throw new Error('Empty catalog')
|
|
175
|
+
} catch {
|
|
176
|
+
models = undefined
|
|
177
|
+
const recovery = await choose(t('Model catalog unavailable. Choose explicitly how to continue.', 'Không tải được danh sách mô hình. Chọn cách tiếp tục.'), [
|
|
178
|
+
{ label: t('Cancel', 'Hủy'), value: 'cancel' },
|
|
179
|
+
{ label: t('Retry catalog', 'Tải lại danh sách'), value: 'retry' },
|
|
180
|
+
{ label: t('Manual model ID', 'Nhập mã mô hình'), value: 'manual', description: t('Enter a public model ID yourself', 'Tự nhập mã mô hình công khai') }
|
|
181
|
+
])
|
|
182
|
+
if (recovery === 'cancel') return
|
|
183
|
+
manual = recovery === 'manual'
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const pickModel = async label => manual
|
|
187
|
+
? modelQuestion(input, output, language)
|
|
188
|
+
: choose(t(`Select public model for ${label}`, `Chọn mô hình công khai cho ${label}`), models.map(model => ({ label: model.name || model.id, value: model.id, description: model.name && model.name !== model.id ? model.id : undefined })))
|
|
189
|
+
if (tool === 'dsh') argv.push('--model', await pickModel('DSH'))
|
|
190
|
+
else {
|
|
191
|
+
const roles = claudeAdapter.CLAUDE_ROLES
|
|
192
|
+
const optionalRoles = claudeAdapter.CLAUDE_OPTIONAL_ROLES || []
|
|
193
|
+
let activeRoles = [...roles]
|
|
194
|
+
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.'), [
|
|
196
|
+
{ label: t('Standard Haiku / Sonnet / Opus', 'Các cấp chuẩn Haiku / Sonnet / Opus'), value: false },
|
|
197
|
+
{ 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
|
+
])
|
|
199
|
+
if (extra) activeRoles.push(...optionalRoles)
|
|
200
|
+
}
|
|
201
|
+
const selected = {}
|
|
202
|
+
for (const role of activeRoles) {
|
|
203
|
+
const id = typeof role === 'string' ? role : role.id
|
|
204
|
+
selected[id] = await pickModel(typeof role === 'string' ? role : role.label)
|
|
205
|
+
argv.push(`--${id}-model`, selected[id])
|
|
206
|
+
}
|
|
207
|
+
argv.push('--model', selected.sonnet)
|
|
208
|
+
}
|
|
209
|
+
if (tool === 'claude') output.write(colorize(t('Claude safety: project/local settings, hooks and permissions are excluded. Managed policy remains active; no permission bypass is enabled.\n', 'An toàn Claude: không dùng cài đặt dự án/cục bộ, móc thực thi và quyền của dự án. Chính sách quản trị vẫn áp dụng; không bật bỏ qua quyền.\n'), 'warning', { output }))
|
|
210
|
+
if (!await confirm(t(`${action === 'setup' ? 'Create' : 'Update'} helper-owned ${tool} configuration? Existing user settings are not edited.`, `${action === 'setup' ? 'Tạo' : 'Cập nhật'} cấu hình riêng cho ${tool}? Không sửa cài đặt hiện có của người dùng.`))) return
|
|
211
|
+
}
|
|
212
|
+
if (action === 'run' && tool === 'dsh') {
|
|
213
|
+
const profile = await choose(t('Choose DSH launch profile', 'Chọn chế độ khởi chạy DSH'), [
|
|
214
|
+
{ label: t('Default (web)', 'Mặc định (web)'), value: null },
|
|
215
|
+
...['web', 'headless', 'acp', 'sdk'].map(value => ({ label: value, value }))
|
|
216
|
+
])
|
|
217
|
+
if (profile) argv.push('--profile', profile)
|
|
218
|
+
}
|
|
219
|
+
if (action === 'remove') {
|
|
220
|
+
const mode = await choose(t('Remove helper configuration', 'Xóa cấu hình riêng'), [
|
|
221
|
+
{ label: t('Cancel', 'Hủy'), value: 'cancel' },
|
|
222
|
+
{ label: t('Normal removal', 'Xóa thông thường'), value: 'normal', description: t('Validate ownership and integrity', 'Kiểm tra quyền sở hữu và tính toàn vẹn') },
|
|
223
|
+
{ label: t('Force removal recovery', 'Khôi phục bằng xóa cưỡng chế'), value: 'force', description: t('WARNING: modified fixed helper files may be deleted without backup', 'CẢNH BÁO: tệp cấu hình đã sửa có thể bị xóa mà không sao lưu') }
|
|
224
|
+
])
|
|
225
|
+
if (mode === 'cancel') return
|
|
226
|
+
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 }))
|
|
227
|
+
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
|
+
argv.push('--yes')
|
|
229
|
+
if (mode === 'force') argv.push('--force')
|
|
230
|
+
}
|
|
231
|
+
const result = await dispatch(argv, { language })
|
|
232
|
+
if (action === 'setup' || action === 'reconfigure') {
|
|
233
|
+
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
|
+
const colored = colorize(warning, 'warning', { output })
|
|
235
|
+
output.write(colored === warning ? warning : `\u001b[1m${colored}\u001b[0m`)
|
|
236
|
+
}
|
|
237
|
+
return result
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error.code === 'UI_CANCELLED') {
|
|
240
|
+
output.write(colorize(t('Cancelled; no action taken.\n', 'Đã hủy; không thực hiện thao tác.\n'), 'warning', { output }))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
throw error
|
|
244
|
+
}
|
|
245
|
+
}
|