@1endpoint/coding-helper 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- # Temporary Holding Version
1
+ # @1endpoint/coding-helper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 [release checklist](docs/RELEASE.md) for current verification boundaries and publishing steps. 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,45 @@
1
+ # Release and verification gate
2
+
3
+ ## Scope
4
+
5
+ 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.
6
+
7
+ ## Architecture and support plan
8
+
9
+ 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.
10
+ 2. Keep original tool settings untouched. Do not migrate plugins, sessions, permissions, hooks or credentials implicitly. Native coding tools remain owners of agent execution.
11
+ 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.
12
+ 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.
13
+ 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.
14
+
15
+ ## Required local checks
16
+
17
+ ```bash
18
+ yarn install --immutable
19
+ yarn check
20
+ yarn test
21
+ yarn pack --out /absolute/reviewed/output/coding-helper-0.1.0.tgz
22
+ ```
23
+
24
+ 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.
25
+
26
+ 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.
27
+
28
+ 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.
29
+
30
+ ## Before npm publication
31
+
32
+ - Confirm repository source, initial commit and remote branch match reviewed package files.
33
+ - Confirm no release blocker in test output or security review, and inspect tarball.
34
+ - 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.
35
+ - Re-run the checks above from the committed source.
36
+ - Maintainer may publish with Yarn's npm publish command, public access, and the desired release tag. This task deliberately does not run it.
37
+ - After publication verify registry version, integrity, bin mapping and real `npx @1endpoint/coding-helper --version` from a fresh environment. Only then remove the Console/docs unpublished warning, commit/deploy the Console update through its normal pipeline, and announce availability.
38
+
39
+ ## Verification record
40
+
41
+ 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.
42
+
43
+ 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.
44
+
45
+ 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
+ ]
6
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
+ }
@@ -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
+ }