cldz-cli 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/README.md +124 -0
- package/bin/cldz.js +13 -0
- package/package.json +37 -0
- package/src/auth.js +85 -0
- package/src/config.js +80 -0
- package/src/index.js +188 -0
- package/src/manager.js +159 -0
- package/src/paths.js +21 -0
- package/src/run.js +179 -0
- package/src/tty.js +131 -0
- package/src/wizard.js +129 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# cldz
|
|
2
|
+
|
|
3
|
+
A one-command launcher for [Claude Code](https://claude.com/claude-code). Pick an
|
|
4
|
+
authentication method **once** — API key, OAuth token, a custom gateway, Amazon
|
|
5
|
+
Bedrock, or Google Vertex AI — then just run `cldz`. It sets the right
|
|
6
|
+
environment variables and hands off to `claude`, passing through any arguments.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx cldz # first run walks you through setup, then launches Claude Code
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or install it globally:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g cldz
|
|
16
|
+
cldz
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Running Claude Code with different credentials normally means remembering and
|
|
22
|
+
exporting the right environment variables every time:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export ANTHROPIC_API_KEY=sk-ant-... # or
|
|
26
|
+
export CLAUDE_CODE_OAUTH_TOKEN=... # or
|
|
27
|
+
export CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-east-1
|
|
28
|
+
claude
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`cldz` remembers your choices in `~/.cldz/config.json` and does that for you.
|
|
32
|
+
|
|
33
|
+
## Auth methods
|
|
34
|
+
|
|
35
|
+
| Method | Env vars it sets |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| **API key** | `ANTHROPIC_API_KEY` |
|
|
38
|
+
| **OAuth token** (Pro / Max, from `claude setup-token`) | `CLAUDE_CODE_OAUTH_TOKEN` |
|
|
39
|
+
| **Custom gateway / proxy** | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` |
|
|
40
|
+
| **Amazon Bedrock** | `CLAUDE_CODE_USE_BEDROCK=1`, `AWS_REGION`, `AWS_PROFILE` |
|
|
41
|
+
| **Google Vertex AI** | `CLAUDE_CODE_USE_VERTEX=1`, `ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION` |
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cldz # launch with your default profile
|
|
47
|
+
cldz -P work # launch with the "work" profile
|
|
48
|
+
cldz -P work "fix the bug" # extra args pass straight to claude
|
|
49
|
+
cldz --dangerously-skip-permissions # any claude flag passes through
|
|
50
|
+
cldz -- --help # force everything after -- through to claude
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Managing profiles
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cldz --config # interactive: add / edit / delete / rename / set-default
|
|
57
|
+
cldz --list # list saved profiles
|
|
58
|
+
cldz --set-default work # set the default profile
|
|
59
|
+
cldz --remove old # delete a profile
|
|
60
|
+
cldz --env work # show the env vars a profile sets (secrets masked)
|
|
61
|
+
cldz --doctor # check your setup
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `--config` menu lets you add multiple named profiles, edit them, delete them,
|
|
65
|
+
rename them, and choose which one is the default.
|
|
66
|
+
|
|
67
|
+
## Credential storage
|
|
68
|
+
|
|
69
|
+
- When you opt in during setup, secrets are saved to `~/.cldz/config.json`
|
|
70
|
+
(written with `0600` permissions). On Windows this is `%USERPROFILE%\.cldz\config.json`.
|
|
71
|
+
- If you'd rather **not** store a secret, decline the "save the secret?" prompt.
|
|
72
|
+
`cldz` will then read it from the environment each run.
|
|
73
|
+
- **A matching environment variable always overrides the saved value at run time.**
|
|
74
|
+
So you can keep a profile for its type/region settings and inject the secret from
|
|
75
|
+
your shell or a secrets manager:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
ANTHROPIC_API_KEY=sk-ant-xxxx cldz -P work
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Session isolation
|
|
82
|
+
|
|
83
|
+
By default, each profile launches `claude` with its **own** `CLAUDE_CONFIG_DIR`
|
|
84
|
+
at `~/.cldz/sessions/<profile>/`. This matters:
|
|
85
|
+
|
|
86
|
+
- **The profile's credential is guaranteed to be the one used.** If you're already
|
|
87
|
+
logged into Claude Code normally, that stored login would otherwise take
|
|
88
|
+
precedence over an injected token — isolation prevents that.
|
|
89
|
+
- Each profile gets its own separate session, history, and settings. Your main
|
|
90
|
+
`~/.claude` login is never touched.
|
|
91
|
+
|
|
92
|
+
The first launch of a fresh profile runs Claude Code's normal first-run setup
|
|
93
|
+
(theme, trust prompt) inside that profile's dir. You can turn isolation off for a
|
|
94
|
+
profile in `cldz --config` (it will then share your main `~/.claude`), or set
|
|
95
|
+
`CLAUDE_CONFIG_DIR` yourself to override where it points.
|
|
96
|
+
|
|
97
|
+
## Environment variables
|
|
98
|
+
|
|
99
|
+
| Variable | Purpose |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `CLDZ_HOME` | Override the config directory (default `~/.cldz`) |
|
|
102
|
+
| `CLDZ_CLAUDE_BIN` | Path to the `claude` binary (default `claude`) |
|
|
103
|
+
| `CLDZ_PROFILE` | Default profile name to use |
|
|
104
|
+
| `CLAUDE_CONFIG_DIR` | If set, cldz respects it instead of the per-profile isolated dir |
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
- Node.js >= 18
|
|
109
|
+
- [Claude Code](https://claude.com/claude-code) installed (`npm i -g @anthropic-ai/claude-code`)
|
|
110
|
+
|
|
111
|
+
## Support & Contributing
|
|
112
|
+
|
|
113
|
+
Questions, bug reports, and contributions are welcome.
|
|
114
|
+
|
|
115
|
+
- **Support / bugs:** email [iamthehimansh@gmail.com](mailto:iamthehimansh@gmail.com)
|
|
116
|
+
- **Contributing:** PRs and issues are welcome. Please include steps to reproduce
|
|
117
|
+
for bugs, and keep the CLI dependency-free (it intentionally ships with zero
|
|
118
|
+
runtime dependencies). Run the CLI locally with `node bin/cldz.js` while developing.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT © [iamthehimansh](mailto:iamthehimansh@gmail.com)
|
|
123
|
+
|
|
124
|
+
MIT
|
package/bin/cldz.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { main } = require('../src/index.js');
|
|
5
|
+
|
|
6
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
7
|
+
if (err && err.code === 'CLDZ_ABORT') {
|
|
8
|
+
// User cancelled a prompt. Exit quietly.
|
|
9
|
+
process.exit(130);
|
|
10
|
+
}
|
|
11
|
+
console.error('cldz: ' + (err && err.message ? err.message : String(err)));
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cldz-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command launcher for Claude Code — pick an auth method (API key / OAuth / gateway / Bedrock / Vertex) once and just run `cldz`.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cldz": "bin/cldz.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/cldz.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"claude",
|
|
22
|
+
"claude-code",
|
|
23
|
+
"anthropic",
|
|
24
|
+
"cli",
|
|
25
|
+
"launcher",
|
|
26
|
+
"oauth",
|
|
27
|
+
"api-key",
|
|
28
|
+
"bedrock",
|
|
29
|
+
"vertex"
|
|
30
|
+
],
|
|
31
|
+
"author": "iamthehimansh <iamthehimansh@gmail.com>",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"email": "iamthehimansh@gmail.com"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {}
|
|
37
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Definitions for every supported auth method and how each maps to the
|
|
4
|
+
// environment variables Claude Code reads.
|
|
5
|
+
//
|
|
6
|
+
// Each field:
|
|
7
|
+
// key - property name stored on the profile
|
|
8
|
+
// label - prompt text
|
|
9
|
+
// env - the environment variable Claude Code reads
|
|
10
|
+
// secret - true => hidden input + treated as a secret
|
|
11
|
+
// optional - true => may be left blank
|
|
12
|
+
// default - default value offered at the prompt
|
|
13
|
+
const AUTH_TYPES = {
|
|
14
|
+
apiKey: {
|
|
15
|
+
label: 'API key',
|
|
16
|
+
hint: 'ANTHROPIC_API_KEY',
|
|
17
|
+
fields: [
|
|
18
|
+
{ key: 'apiKey', label: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', secret: true },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
oauth: {
|
|
22
|
+
label: 'OAuth token (Pro / Max)',
|
|
23
|
+
hint: 'from `claude setup-token`',
|
|
24
|
+
fields: [
|
|
25
|
+
{ key: 'oauthToken', label: 'Claude Code OAuth token', env: 'CLAUDE_CODE_OAUTH_TOKEN', secret: true },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
gateway: {
|
|
29
|
+
label: 'Custom gateway / proxy',
|
|
30
|
+
hint: 'ANTHROPIC_BASE_URL',
|
|
31
|
+
fields: [
|
|
32
|
+
{ key: 'baseUrl', label: 'Base URL (e.g. https://gateway.example.com)', env: 'ANTHROPIC_BASE_URL', secret: false },
|
|
33
|
+
{ key: 'authToken', label: 'Auth token (blank if the gateway needs none)', env: 'ANTHROPIC_AUTH_TOKEN', secret: true, optional: true },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
bedrock: {
|
|
37
|
+
label: 'Amazon Bedrock',
|
|
38
|
+
hint: 'CLAUDE_CODE_USE_BEDROCK',
|
|
39
|
+
staticEnv: { CLAUDE_CODE_USE_BEDROCK: '1' },
|
|
40
|
+
note: 'Uses your standard AWS credentials (env, ~/.aws, or SSO).',
|
|
41
|
+
fields: [
|
|
42
|
+
{ key: 'region', label: 'AWS region', env: 'AWS_REGION', secret: false, default: 'us-east-1' },
|
|
43
|
+
{ key: 'awsProfile', label: 'AWS profile (blank = default credentials)', env: 'AWS_PROFILE', secret: false, optional: true },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
vertex: {
|
|
47
|
+
label: 'Google Vertex AI',
|
|
48
|
+
hint: 'CLAUDE_CODE_USE_VERTEX',
|
|
49
|
+
staticEnv: { CLAUDE_CODE_USE_VERTEX: '1' },
|
|
50
|
+
note: 'Uses Google Application Default Credentials (gcloud auth).',
|
|
51
|
+
fields: [
|
|
52
|
+
{ key: 'project', label: 'GCP project ID', env: 'ANTHROPIC_VERTEX_PROJECT_ID', secret: false },
|
|
53
|
+
{ key: 'region', label: 'Cloud ML region', env: 'CLOUD_ML_REGION', secret: false, default: 'us-east5' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const TYPE_ORDER = ['apiKey', 'oauth', 'gateway', 'bedrock', 'vertex'];
|
|
59
|
+
|
|
60
|
+
function typeDef(type) {
|
|
61
|
+
const def = AUTH_TYPES[type];
|
|
62
|
+
if (!def) throw new Error(`unknown auth type: ${type}`);
|
|
63
|
+
return def;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isSecretField(field) {
|
|
67
|
+
return Boolean(field.secret);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build the env var map to hand to `claude` from a fully-resolved profile
|
|
71
|
+
// (i.e. one whose field values are already populated).
|
|
72
|
+
function buildEnv(profile) {
|
|
73
|
+
const def = typeDef(profile.type);
|
|
74
|
+
const env = {};
|
|
75
|
+
if (def.staticEnv) Object.assign(env, def.staticEnv);
|
|
76
|
+
for (const field of def.fields) {
|
|
77
|
+
const value = profile[field.key];
|
|
78
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
79
|
+
env[field.env] = String(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return env;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { AUTH_TYPES, TYPE_ORDER, typeDef, buildEnv, isSecretField };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { configDir, configPath } = require('./paths.js');
|
|
5
|
+
|
|
6
|
+
const CONFIG_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
function emptyConfig() {
|
|
9
|
+
return { version: CONFIG_VERSION, defaultProfile: null, profiles: {} };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function load() {
|
|
13
|
+
const file = configPath();
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code === 'ENOENT') return emptyConfig();
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
let data;
|
|
22
|
+
try {
|
|
23
|
+
data = JSON.parse(raw);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw new Error(`config file at ${file} is not valid JSON (${err.message}). Fix or delete it.`);
|
|
26
|
+
}
|
|
27
|
+
if (!data || typeof data !== 'object') return emptyConfig();
|
|
28
|
+
if (!data.profiles || typeof data.profiles !== 'object') data.profiles = {};
|
|
29
|
+
if (typeof data.version !== 'number') data.version = CONFIG_VERSION;
|
|
30
|
+
if (!('defaultProfile' in data)) data.defaultProfile = null;
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function save(data) {
|
|
35
|
+
const dir = configDir();
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
const file = configPath();
|
|
38
|
+
// Write with restrictive perms (0600) since it may hold plaintext secrets.
|
|
39
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
40
|
+
if (process.platform !== 'win32') {
|
|
41
|
+
try {
|
|
42
|
+
fs.chmodSync(file, 0o600);
|
|
43
|
+
} catch {
|
|
44
|
+
/* best effort */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return file;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function profileNames(data) {
|
|
51
|
+
return Object.keys(data.profiles);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getProfile(data, name) {
|
|
55
|
+
return data.profiles[name] || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setDefault(data, name) {
|
|
59
|
+
data.defaultProfile = name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeProfile(data, name) {
|
|
63
|
+
delete data.profiles[name];
|
|
64
|
+
if (data.defaultProfile === name) {
|
|
65
|
+
const remaining = profileNames(data);
|
|
66
|
+
data.defaultProfile = remaining.length ? remaining[0] : null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
CONFIG_VERSION,
|
|
72
|
+
emptyConfig,
|
|
73
|
+
load,
|
|
74
|
+
save,
|
|
75
|
+
profileNames,
|
|
76
|
+
getProfile,
|
|
77
|
+
setDefault,
|
|
78
|
+
removeProfile,
|
|
79
|
+
configPath,
|
|
80
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
const config = require('./config.js');
|
|
5
|
+
const manager = require('./manager.js');
|
|
6
|
+
const { run } = require('./run.js');
|
|
7
|
+
const tty = require('./tty.js');
|
|
8
|
+
const { paint, colors: c } = tty;
|
|
9
|
+
|
|
10
|
+
const pkg = require('../package.json');
|
|
11
|
+
|
|
12
|
+
function printVersion() {
|
|
13
|
+
process.stdout.write(`cldz ${pkg.version}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printHelp() {
|
|
17
|
+
const b = (s) => paint(c.bold, s);
|
|
18
|
+
process.stdout.write(`
|
|
19
|
+
${b('cldz')} — a one-command launcher for Claude Code.
|
|
20
|
+
|
|
21
|
+
Pick an auth method once (API key / OAuth / gateway / Bedrock / Vertex),
|
|
22
|
+
then just run ${b('cldz')}. Any extra arguments are passed straight to ${b('claude')}.
|
|
23
|
+
|
|
24
|
+
${b('USAGE')}
|
|
25
|
+
cldz [claude args...] Launch Claude Code with your default profile
|
|
26
|
+
cldz -P <name> [claude args] Launch using a specific profile
|
|
27
|
+
cldz -- <claude args> Force everything through to claude (e.g. -- --help)
|
|
28
|
+
|
|
29
|
+
${b('MANAGEMENT')}
|
|
30
|
+
cldz --config Interactive: add / edit / delete / set-default
|
|
31
|
+
cldz --list List saved profiles
|
|
32
|
+
cldz --set-default <name> Set the default profile
|
|
33
|
+
cldz --remove <name> Delete a profile
|
|
34
|
+
cldz --env [name] Print the env vars a profile sets (secrets masked)
|
|
35
|
+
cldz --doctor Check your setup
|
|
36
|
+
cldz --help Show this help
|
|
37
|
+
cldz --version Show version
|
|
38
|
+
|
|
39
|
+
${b('ISOLATION')}
|
|
40
|
+
By default each profile runs claude with its own CLAUDE_CONFIG_DIR
|
|
41
|
+
(~/.cldz/sessions/<profile>/), so the profile's credential is the one used and
|
|
42
|
+
your main ~/.claude login is untouched. Opt out per profile via ${b('cldz --config')}.
|
|
43
|
+
|
|
44
|
+
${b('ENVIRONMENT')}
|
|
45
|
+
A matching env var always overrides the saved value at run time, e.g.
|
|
46
|
+
${paint(c.dim, 'ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_BASE_URL,')}
|
|
47
|
+
${paint(c.dim, 'ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX')}
|
|
48
|
+
|
|
49
|
+
CLDZ_HOME Override config dir (default: ~/.cldz)
|
|
50
|
+
CLDZ_CLAUDE_BIN Path to the claude binary (default: claude)
|
|
51
|
+
CLDZ_PROFILE Default profile name to use
|
|
52
|
+
|
|
53
|
+
Config is stored at ${paint(c.dim, config.configPath())}
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function doctor() {
|
|
58
|
+
const ok = (s) => paint(c.green, '✓ ') + s;
|
|
59
|
+
const warn = (s) => paint(c.yellow, '! ') + s;
|
|
60
|
+
|
|
61
|
+
process.stdout.write(paint(c.bold, 'cldz doctor\n\n'));
|
|
62
|
+
process.stdout.write(ok(`node ${process.version}`) + '\n');
|
|
63
|
+
|
|
64
|
+
const bin = process.env.CLDZ_CLAUDE_BIN || 'claude';
|
|
65
|
+
try {
|
|
66
|
+
const out = execFileSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
67
|
+
process.stdout.write(ok(`claude found: ${out.trim()}`) + '\n');
|
|
68
|
+
} catch {
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
warn(`could not run "${bin} --version". Install it: npm i -g @anthropic-ai/claude-code`) + '\n'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const data = config.load();
|
|
75
|
+
const names = config.profileNames(data);
|
|
76
|
+
if (!names.length) {
|
|
77
|
+
process.stdout.write(warn('no profiles configured yet — run: cldz --config') + '\n');
|
|
78
|
+
} else {
|
|
79
|
+
process.stdout.write(ok(`${names.length} profile(s): ${names.join(', ')}`) + '\n');
|
|
80
|
+
process.stdout.write(ok(`default profile: ${data.defaultProfile || '(none)'}`) + '\n');
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write(paint(c.dim, `config: ${config.configPath()}\n`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parse argv into { command, profile, claudeArgs, rest }.
|
|
86
|
+
function parse(argv) {
|
|
87
|
+
// Explicit force-passthrough: `cldz -- <claude args>`
|
|
88
|
+
if (argv[0] === '--') {
|
|
89
|
+
return { command: 'run', claudeArgs: argv.slice(1) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const first = argv[0];
|
|
93
|
+
|
|
94
|
+
// Strip a `--profile` / `-P` selector wherever it appears before `--`.
|
|
95
|
+
let profile = process.env.CLDZ_PROFILE || undefined;
|
|
96
|
+
const rest = [];
|
|
97
|
+
for (let i = 0; i < argv.length; i++) {
|
|
98
|
+
const a = argv[i];
|
|
99
|
+
if (a === '--') {
|
|
100
|
+
// Everything after is passthrough; keep the marker so we can strip it later.
|
|
101
|
+
rest.push(...argv.slice(i));
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
if (a === '--profile' || a === '-P') {
|
|
105
|
+
profile = argv[++i];
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (a.startsWith('--profile=')) {
|
|
109
|
+
profile = a.slice('--profile='.length);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
rest.push(a);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Recognize cldz management flags only when they lead the command.
|
|
116
|
+
switch (first) {
|
|
117
|
+
case '--config':
|
|
118
|
+
return { command: 'config' };
|
|
119
|
+
case '--list':
|
|
120
|
+
case '--profiles':
|
|
121
|
+
return { command: 'list' };
|
|
122
|
+
case '--env':
|
|
123
|
+
return { command: 'env', profile: argv[1] };
|
|
124
|
+
case '--doctor':
|
|
125
|
+
return { command: 'doctor' };
|
|
126
|
+
case '--set-default':
|
|
127
|
+
return { command: 'set-default', name: argv[1] };
|
|
128
|
+
case '--remove':
|
|
129
|
+
case '--delete':
|
|
130
|
+
return { command: 'remove', name: argv[1] };
|
|
131
|
+
case '--help':
|
|
132
|
+
case '-h':
|
|
133
|
+
return { command: 'help' };
|
|
134
|
+
case '--version':
|
|
135
|
+
case '-V':
|
|
136
|
+
return { command: 'version' };
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Run path: strip a leading `--` passthrough marker if present.
|
|
142
|
+
let claudeArgs = rest;
|
|
143
|
+
const dd = claudeArgs.indexOf('--');
|
|
144
|
+
if (dd !== -1) claudeArgs = [...claudeArgs.slice(0, dd), ...claudeArgs.slice(dd + 1)];
|
|
145
|
+
return { command: 'run', profile, claudeArgs };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function main(argv) {
|
|
149
|
+
const parsed = parse(argv);
|
|
150
|
+
|
|
151
|
+
switch (parsed.command) {
|
|
152
|
+
case 'help':
|
|
153
|
+
return printHelp();
|
|
154
|
+
case 'version':
|
|
155
|
+
return printVersion();
|
|
156
|
+
case 'doctor':
|
|
157
|
+
return doctor();
|
|
158
|
+
case 'config':
|
|
159
|
+
return manager.manage();
|
|
160
|
+
case 'list':
|
|
161
|
+
return manager.listProfiles();
|
|
162
|
+
case 'env':
|
|
163
|
+
return manager.showEnv(parsed.profile);
|
|
164
|
+
case 'set-default': {
|
|
165
|
+
if (!parsed.name) throw new Error('usage: cldz --set-default <name>');
|
|
166
|
+
const data = config.load();
|
|
167
|
+
if (!data.profiles[parsed.name]) throw new Error(`profile "${parsed.name}" not found`);
|
|
168
|
+
config.setDefault(data, parsed.name);
|
|
169
|
+
config.save(data);
|
|
170
|
+
process.stdout.write(paint(c.green, `✓ Default profile is now "${parsed.name}".\n`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case 'remove': {
|
|
174
|
+
if (!parsed.name) throw new Error('usage: cldz --remove <name>');
|
|
175
|
+
const data = config.load();
|
|
176
|
+
if (!data.profiles[parsed.name]) throw new Error(`profile "${parsed.name}" not found`);
|
|
177
|
+
config.removeProfile(data, parsed.name);
|
|
178
|
+
config.save(data);
|
|
179
|
+
process.stdout.write(paint(c.green, `✓ Removed "${parsed.name}".\n`));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
case 'run':
|
|
183
|
+
default:
|
|
184
|
+
return run({ profile: parsed.profile, claudeArgs: parsed.claudeArgs || [] });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { main, parse };
|
package/src/manager.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('./config.js');
|
|
4
|
+
const { typeDef, buildEnv } = require('./auth.js');
|
|
5
|
+
const { sessionDir } = require('./run.js');
|
|
6
|
+
const wizard = require('./wizard.js');
|
|
7
|
+
const tty = require('./tty.js');
|
|
8
|
+
const { paint, colors: c } = tty;
|
|
9
|
+
|
|
10
|
+
function maskSecrets(profile) {
|
|
11
|
+
const def = typeDef(profile.type);
|
|
12
|
+
const parts = [];
|
|
13
|
+
for (const field of def.fields) {
|
|
14
|
+
const val = profile[field.key];
|
|
15
|
+
if (val === undefined || val === '') continue;
|
|
16
|
+
parts.push(`${field.key}=${field.secret ? mask(val) : val}`);
|
|
17
|
+
}
|
|
18
|
+
return parts.join(' ');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mask(val) {
|
|
22
|
+
const s = String(val);
|
|
23
|
+
if (s.length <= 8) return '••••';
|
|
24
|
+
return s.slice(0, 4) + '…' + s.slice(-4);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printProfiles(data) {
|
|
28
|
+
const names = config.profileNames(data);
|
|
29
|
+
if (!names.length) {
|
|
30
|
+
process.stdout.write(paint(c.dim, ' (no profiles yet)\n'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const p = data.profiles[name];
|
|
35
|
+
const isDefault = data.defaultProfile === name;
|
|
36
|
+
const star = isDefault ? paint(c.green, ' ★ default') : '';
|
|
37
|
+
const def = typeDef(p.type);
|
|
38
|
+
const iso = p.isolate === false ? ' ' + paint(c.yellow, '(shared login)') : '';
|
|
39
|
+
process.stdout.write(` ${paint(c.bold, name)} ${paint(c.dim, def.label)}${star}${iso}\n`);
|
|
40
|
+
const detail = maskSecrets(p);
|
|
41
|
+
if (detail) process.stdout.write(paint(c.dim, ` ${detail}\n`));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function chooseProfile(data, message) {
|
|
46
|
+
const names = config.profileNames(data);
|
|
47
|
+
if (!names.length) return null;
|
|
48
|
+
return tty.select(
|
|
49
|
+
message,
|
|
50
|
+
names.map((n) => ({
|
|
51
|
+
name: n + (data.defaultProfile === n ? ' ★' : ''),
|
|
52
|
+
value: n,
|
|
53
|
+
hint: data.profiles[n].type,
|
|
54
|
+
}))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Interactive config management loop (cldz --config).
|
|
59
|
+
async function manage() {
|
|
60
|
+
const data = config.load();
|
|
61
|
+
process.stdout.write('\n' + paint(c.bold, 'cldz configuration') + ' ' + paint(c.dim, config.configPath()) + '\n\n');
|
|
62
|
+
|
|
63
|
+
for (;;) {
|
|
64
|
+
printProfiles(data);
|
|
65
|
+
process.stdout.write('\n');
|
|
66
|
+
|
|
67
|
+
const action = await tty.select('What would you like to do?', [
|
|
68
|
+
{ name: 'Add a profile', value: 'add' },
|
|
69
|
+
{ name: 'Edit a profile', value: 'edit' },
|
|
70
|
+
{ name: 'Delete a profile', value: 'delete' },
|
|
71
|
+
{ name: 'Set default profile', value: 'default' },
|
|
72
|
+
{ name: 'Rename a profile', value: 'rename' },
|
|
73
|
+
{ name: 'Save & exit', value: 'exit' },
|
|
74
|
+
]).catch((e) => {
|
|
75
|
+
if (e.code === 'CLDZ_ABORT') return 'exit';
|
|
76
|
+
throw e;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
process.stdout.write('\n');
|
|
80
|
+
|
|
81
|
+
if (action === 'exit') break;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (action === 'add') {
|
|
85
|
+
const name = await wizard.configureProfile(data, {});
|
|
86
|
+
config.save(data);
|
|
87
|
+
process.stdout.write(paint(c.green, `✓ Added "${name}".\n\n`));
|
|
88
|
+
} else if (action === 'edit') {
|
|
89
|
+
const name = await chooseProfile(data, 'Edit which profile?');
|
|
90
|
+
if (name) {
|
|
91
|
+
await wizard.configureProfile(data, { name });
|
|
92
|
+
config.save(data);
|
|
93
|
+
process.stdout.write(paint(c.green, `✓ Updated "${name}".\n\n`));
|
|
94
|
+
}
|
|
95
|
+
} else if (action === 'delete') {
|
|
96
|
+
const name = await chooseProfile(data, 'Delete which profile?');
|
|
97
|
+
if (name) {
|
|
98
|
+
const yes = await tty.confirm(`Delete "${name}"?`, { defaultValue: false });
|
|
99
|
+
if (yes) {
|
|
100
|
+
config.removeProfile(data, name);
|
|
101
|
+
config.save(data);
|
|
102
|
+
process.stdout.write(paint(c.green, `✓ Deleted "${name}".\n\n`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (action === 'default') {
|
|
106
|
+
const name = await chooseProfile(data, 'Set which profile as default?');
|
|
107
|
+
if (name) {
|
|
108
|
+
config.setDefault(data, name);
|
|
109
|
+
config.save(data);
|
|
110
|
+
process.stdout.write(paint(c.green, `✓ Default is now "${name}".\n\n`));
|
|
111
|
+
}
|
|
112
|
+
} else if (action === 'rename') {
|
|
113
|
+
const name = await chooseProfile(data, 'Rename which profile?');
|
|
114
|
+
if (name) {
|
|
115
|
+
const next = await tty.ask('New name', { defaultValue: name });
|
|
116
|
+
if (next && next !== name) {
|
|
117
|
+
if (data.profiles[next]) throw new Error(`"${next}" already exists`);
|
|
118
|
+
data.profiles[next] = data.profiles[name];
|
|
119
|
+
if (data.defaultProfile === name) data.defaultProfile = next;
|
|
120
|
+
delete data.profiles[name];
|
|
121
|
+
config.save(data);
|
|
122
|
+
process.stdout.write(paint(c.green, `✓ Renamed to "${next}".\n\n`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err.code === 'CLDZ_ABORT') {
|
|
128
|
+
process.stdout.write(paint(c.dim, '(cancelled)\n\n'));
|
|
129
|
+
} else {
|
|
130
|
+
process.stdout.write(paint(c.red, `✗ ${err.message}\n\n`));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
process.stdout.write(paint(c.dim, 'Saved. Run `cldz` to launch Claude Code.\n'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Non-interactive helpers used by subcommands.
|
|
139
|
+
function listProfiles() {
|
|
140
|
+
const data = config.load();
|
|
141
|
+
process.stdout.write(paint(c.bold, 'Profiles') + ' ' + paint(c.dim, config.configPath()) + '\n');
|
|
142
|
+
printProfiles(data);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function showEnv(profileName) {
|
|
146
|
+
const data = config.load();
|
|
147
|
+
const name = profileName || data.defaultProfile || config.profileNames(data)[0];
|
|
148
|
+
if (!name || !data.profiles[name]) throw new Error('no profile configured. Run: cldz --config');
|
|
149
|
+
const p = data.profiles[name];
|
|
150
|
+
const env = buildEnv({ ...p });
|
|
151
|
+
if (p.isolate !== false) env.CLAUDE_CONFIG_DIR = sessionDir(name, p);
|
|
152
|
+
process.stdout.write(paint(c.dim, `# profile "${name}"\n`));
|
|
153
|
+
for (const [k, v] of Object.entries(env)) {
|
|
154
|
+
const secret = /TOKEN|KEY/.test(k);
|
|
155
|
+
process.stdout.write(`export ${k}=${secret ? mask(v) : v}\n`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { manage, listProfiles, showEnv, chooseProfile };
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
// Resolve the cldz home directory in a cross-platform way.
|
|
7
|
+
// Override with CLDZ_HOME for tests / custom locations.
|
|
8
|
+
// - macOS / Linux: ~/.cldz
|
|
9
|
+
// - Windows: C:\Users\<you>\.cldz (os.homedir() -> %USERPROFILE%)
|
|
10
|
+
function configDir() {
|
|
11
|
+
if (process.env.CLDZ_HOME && process.env.CLDZ_HOME.trim()) {
|
|
12
|
+
return process.env.CLDZ_HOME;
|
|
13
|
+
}
|
|
14
|
+
return path.join(os.homedir(), '.cldz');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function configPath() {
|
|
18
|
+
return path.join(configDir(), 'config.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { configDir, configPath };
|
package/src/run.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { typeDef, buildEnv } = require('./auth.js');
|
|
7
|
+
const config = require('./config.js');
|
|
8
|
+
const { configDir } = require('./paths.js');
|
|
9
|
+
const wizard = require('./wizard.js');
|
|
10
|
+
const tty = require('./tty.js');
|
|
11
|
+
const { paint, colors: c } = tty;
|
|
12
|
+
|
|
13
|
+
// Where an isolated profile keeps its own Claude Code config/credentials.
|
|
14
|
+
function sessionDir(name, stored) {
|
|
15
|
+
return stored.configDir || path.join(configDir(), 'sessions', name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Unless the profile opts out (isolate: false) or the user already set
|
|
19
|
+
// CLAUDE_CONFIG_DIR themselves, point claude at a per-profile config dir so the
|
|
20
|
+
// profile's credential is the one actually used — a stored login in ~/.claude
|
|
21
|
+
// otherwise takes precedence over an injected token. Returns the dir, or null.
|
|
22
|
+
function applyIsolation(extraEnv, name, stored) {
|
|
23
|
+
if (stored.isolate === false) return null;
|
|
24
|
+
if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR; // env override wins
|
|
25
|
+
const dir = sessionDir(name, stored);
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
extraEnv.CLAUDE_CONFIG_DIR = dir;
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Resolve a stored profile into concrete field values, letting a runtime
|
|
32
|
+
// environment variable override the saved value. Prompts for anything that is
|
|
33
|
+
// still missing (when interactive) and offers to save it.
|
|
34
|
+
async function resolveProfile(data, name) {
|
|
35
|
+
const stored = data.profiles[name];
|
|
36
|
+
if (!stored) throw new Error(`profile "${name}" not found`);
|
|
37
|
+
const def = typeDef(stored.type);
|
|
38
|
+
|
|
39
|
+
const resolved = { type: stored.type };
|
|
40
|
+
const sources = {};
|
|
41
|
+
const missing = [];
|
|
42
|
+
|
|
43
|
+
for (const field of def.fields) {
|
|
44
|
+
const envVal = process.env[field.env];
|
|
45
|
+
if (envVal !== undefined && envVal !== '') {
|
|
46
|
+
resolved[field.key] = envVal;
|
|
47
|
+
sources[field.key] = 'env';
|
|
48
|
+
} else if (stored[field.key] !== undefined && stored[field.key] !== '') {
|
|
49
|
+
resolved[field.key] = stored[field.key];
|
|
50
|
+
sources[field.key] = 'config';
|
|
51
|
+
} else if (!field.optional) {
|
|
52
|
+
missing.push(field);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (missing.length) {
|
|
57
|
+
if (!tty.isInteractive()) {
|
|
58
|
+
const names = missing.map((f) => '$' + f.env).join(', ');
|
|
59
|
+
throw new Error(
|
|
60
|
+
`profile "${name}" is missing required values (${names}). ` +
|
|
61
|
+
`Set the env var(s) or run: cldz --config`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
paint(c.yellow, `Profile "${name}" needs a few values:`) + '\n'
|
|
66
|
+
);
|
|
67
|
+
let touched = false;
|
|
68
|
+
for (const field of missing) {
|
|
69
|
+
const val = field.secret
|
|
70
|
+
? await tty.askSecret(' ' + field.label)
|
|
71
|
+
: await tty.ask(' ' + field.label, { defaultValue: field.default || '' });
|
|
72
|
+
if (!val && !field.optional) throw new Error(`${field.label} is required`);
|
|
73
|
+
if (val) {
|
|
74
|
+
resolved[field.key] = val;
|
|
75
|
+
sources[field.key] = 'prompt';
|
|
76
|
+
touched = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (touched) {
|
|
80
|
+
const store = await tty.confirm('Save these to the profile?', { defaultValue: true });
|
|
81
|
+
if (store) {
|
|
82
|
+
for (const field of missing) {
|
|
83
|
+
if (sources[field.key] === 'prompt') stored[field.key] = resolved[field.key];
|
|
84
|
+
}
|
|
85
|
+
config.save(data);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { resolved, sources };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickDefaultProfileName(data) {
|
|
94
|
+
const names = config.profileNames(data);
|
|
95
|
+
if (data.defaultProfile && data.profiles[data.defaultProfile]) return data.defaultProfile;
|
|
96
|
+
if (names.length === 1) return names[0];
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Determine which profile to run, creating one if none exist.
|
|
101
|
+
async function determineProfile(data, requested) {
|
|
102
|
+
if (requested) {
|
|
103
|
+
if (!data.profiles[requested]) {
|
|
104
|
+
throw new Error(`profile "${requested}" not found. Run: cldz --config`);
|
|
105
|
+
}
|
|
106
|
+
return requested;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const names = config.profileNames(data);
|
|
110
|
+
if (names.length === 0) {
|
|
111
|
+
process.stdout.write(paint(c.bold, 'Welcome to cldz — let’s set up authentication.\n\n'));
|
|
112
|
+
const name = await wizard.configureProfile(data, {});
|
|
113
|
+
config.save(data);
|
|
114
|
+
process.stdout.write(paint(c.green, `\n✓ Saved profile "${name}".\n\n`));
|
|
115
|
+
return name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const chosen = pickDefaultProfileName(data);
|
|
119
|
+
if (chosen) return chosen;
|
|
120
|
+
|
|
121
|
+
// Multiple profiles, no default -> ask.
|
|
122
|
+
const choice = await tty.select(
|
|
123
|
+
'Which profile?',
|
|
124
|
+
names.map((n) => ({ name: n, value: n, hint: data.profiles[n].type }))
|
|
125
|
+
);
|
|
126
|
+
return choice;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function launchClaude(claudeArgs, extraEnv) {
|
|
130
|
+
// Release stdin (any open prompt interface) before claude inherits it.
|
|
131
|
+
tty.close();
|
|
132
|
+
const bin = process.env.CLDZ_CLAUDE_BIN || 'claude';
|
|
133
|
+
const env = { ...process.env, ...extraEnv };
|
|
134
|
+
const child = spawn(bin, claudeArgs, {
|
|
135
|
+
stdio: 'inherit',
|
|
136
|
+
env,
|
|
137
|
+
shell: process.platform === 'win32',
|
|
138
|
+
});
|
|
139
|
+
child.on('error', (err) => {
|
|
140
|
+
if (err.code === 'ENOENT') {
|
|
141
|
+
console.error(
|
|
142
|
+
`cldz: could not find "${bin}". Install Claude Code first:\n` +
|
|
143
|
+
' npm install -g @anthropic-ai/claude-code'
|
|
144
|
+
);
|
|
145
|
+
process.exit(127);
|
|
146
|
+
}
|
|
147
|
+
console.error('cldz: failed to launch claude — ' + err.message);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
150
|
+
child.on('exit', (code, signal) => {
|
|
151
|
+
if (signal) {
|
|
152
|
+
process.kill(process.pid, signal);
|
|
153
|
+
} else {
|
|
154
|
+
process.exit(code == null ? 0 : code);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Entry point for the "run" path.
|
|
160
|
+
async function run({ profile: requested, claudeArgs, quiet }) {
|
|
161
|
+
const data = config.load();
|
|
162
|
+
const name = await determineProfile(data, requested);
|
|
163
|
+
const { resolved, sources } = await resolveProfile(data, name);
|
|
164
|
+
const extraEnv = buildEnv(resolved);
|
|
165
|
+
const isolatedDir = applyIsolation(extraEnv, name, data.profiles[name]);
|
|
166
|
+
|
|
167
|
+
if (!quiet && process.stdout.isTTY) {
|
|
168
|
+
const def = typeDef(resolved.type);
|
|
169
|
+
const via = Object.values(sources).includes('env') ? paint(c.dim, ' (env override)') : '';
|
|
170
|
+
const iso = isolatedDir ? paint(c.dim, ' · isolated session') : '';
|
|
171
|
+
process.stderr.write(
|
|
172
|
+
paint(c.dim, `cldz › profile "${name}" · ${def.label}${via}${iso}\n`)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
launchClaude(claudeArgs, extraEnv);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { run, resolveProfile, determineProfile, launchClaude, sessionDir, applyIsolation };
|
package/src/tty.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('node:readline');
|
|
4
|
+
|
|
5
|
+
const c = {
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
dim: '\x1b[90m',
|
|
8
|
+
cyan: '\x1b[36m',
|
|
9
|
+
green: '\x1b[32m',
|
|
10
|
+
yellow: '\x1b[33m',
|
|
11
|
+
red: '\x1b[31m',
|
|
12
|
+
bold: '\x1b[1m',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
16
|
+
function paint(color, str) {
|
|
17
|
+
return useColor ? color + str + c.reset : str;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isInteractive() {
|
|
21
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One shared readline interface with a persistent line queue. `readline`'s
|
|
25
|
+
// `question()` only captures a line while its transient listener is attached,
|
|
26
|
+
// so piped input emitted between two awaited prompts is lost. Buffering every
|
|
27
|
+
// line into a queue makes reads reliable for interactive TTYs and pipes alike.
|
|
28
|
+
let rl = null;
|
|
29
|
+
let muted = false;
|
|
30
|
+
let ended = false;
|
|
31
|
+
const queue = [];
|
|
32
|
+
let waiter = null;
|
|
33
|
+
|
|
34
|
+
function getRl() {
|
|
35
|
+
if (rl) return rl;
|
|
36
|
+
rl = readline.createInterface({
|
|
37
|
+
input: process.stdin,
|
|
38
|
+
output: process.stdout,
|
|
39
|
+
terminal: Boolean(process.stdin.isTTY),
|
|
40
|
+
});
|
|
41
|
+
// Suppress echo while reading secrets.
|
|
42
|
+
rl._writeToOutput = (str) => {
|
|
43
|
+
if (!muted) rl.output.write(str);
|
|
44
|
+
};
|
|
45
|
+
rl.on('line', (line) => {
|
|
46
|
+
if (waiter) {
|
|
47
|
+
const w = waiter;
|
|
48
|
+
waiter = null;
|
|
49
|
+
w(line);
|
|
50
|
+
} else {
|
|
51
|
+
queue.push(line);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
rl.on('close', () => {
|
|
55
|
+
ended = true;
|
|
56
|
+
if (waiter) {
|
|
57
|
+
const w = waiter;
|
|
58
|
+
waiter = null;
|
|
59
|
+
w(null);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
rl.on('SIGINT', () => {
|
|
63
|
+
close();
|
|
64
|
+
process.stdout.write('\n');
|
|
65
|
+
process.exit(130);
|
|
66
|
+
});
|
|
67
|
+
return rl;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nextLine() {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
if (queue.length) return resolve(queue.shift());
|
|
73
|
+
if (ended) return resolve(null);
|
|
74
|
+
waiter = resolve;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function close() {
|
|
79
|
+
if (rl) {
|
|
80
|
+
rl.close();
|
|
81
|
+
rl = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function ask(query, { defaultValue = '' } = {}) {
|
|
86
|
+
getRl();
|
|
87
|
+
const suffix = defaultValue ? paint(c.dim, ` (${defaultValue})`) : '';
|
|
88
|
+
process.stdout.write(`${query}${suffix}: `);
|
|
89
|
+
const line = await nextLine();
|
|
90
|
+
if (line === null) return defaultValue; // EOF
|
|
91
|
+
const val = line.trim();
|
|
92
|
+
return val === '' ? defaultValue : val;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Hidden input for secrets (echo suppressed on a TTY; unavoidable on a pipe).
|
|
96
|
+
async function askSecret(query) {
|
|
97
|
+
getRl();
|
|
98
|
+
process.stdout.write(`${query}: `);
|
|
99
|
+
muted = true;
|
|
100
|
+
const line = await nextLine();
|
|
101
|
+
muted = false;
|
|
102
|
+
process.stdout.write('\n');
|
|
103
|
+
return line === null ? '' : line.trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function confirm(query, { defaultValue = true } = {}) {
|
|
107
|
+
const hint = defaultValue ? 'Y/n' : 'y/N';
|
|
108
|
+
const answer = (await ask(`${query} ${paint(c.dim, '[' + hint + ']')}`)).toLowerCase();
|
|
109
|
+
if (answer === '') return defaultValue;
|
|
110
|
+
return answer === 'y' || answer === 'yes';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Numbered single-choice menu. Reliable across every terminal and on pipes.
|
|
114
|
+
// choices: [{ name, value, hint }]
|
|
115
|
+
async function select(message, choices) {
|
|
116
|
+
process.stdout.write(paint(c.bold, message) + '\n');
|
|
117
|
+
choices.forEach((choice, i) => {
|
|
118
|
+
const hint = choice.hint ? paint(c.dim, ` (${choice.hint})`) : '';
|
|
119
|
+
process.stdout.write(` ${paint(c.cyan, String(i + 1))}) ${choice.name}${hint}\n`);
|
|
120
|
+
});
|
|
121
|
+
for (;;) {
|
|
122
|
+
const answer = await ask('Enter number', { defaultValue: '1' });
|
|
123
|
+
const n = parseInt(answer, 10);
|
|
124
|
+
if (!Number.isNaN(n) && n >= 1 && n <= choices.length) {
|
|
125
|
+
return choices[n - 1].value;
|
|
126
|
+
}
|
|
127
|
+
process.stdout.write(paint(c.red, ` Please enter 1-${choices.length}.\n`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { ask, askSecret, confirm, select, isInteractive, close, paint, colors: c };
|
package/src/wizard.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AUTH_TYPES, TYPE_ORDER, typeDef } = require('./auth.js');
|
|
4
|
+
const tty = require('./tty.js');
|
|
5
|
+
const { paint, colors: c } = tty;
|
|
6
|
+
|
|
7
|
+
function suggestName(config, type) {
|
|
8
|
+
const base = type === 'apiKey' ? 'apikey' : type;
|
|
9
|
+
if (!config.profiles[base]) return base;
|
|
10
|
+
let i = 2;
|
|
11
|
+
while (config.profiles[`${base}${i}`]) i++;
|
|
12
|
+
return `${base}${i}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function pickType(current) {
|
|
16
|
+
const choices = TYPE_ORDER.map((type) => ({
|
|
17
|
+
name: AUTH_TYPES[type].label + (type === current ? paint(c.dim, ' (current)') : ''),
|
|
18
|
+
value: type,
|
|
19
|
+
hint: AUTH_TYPES[type].hint,
|
|
20
|
+
}));
|
|
21
|
+
return tty.select('Which authentication method?', choices);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Prompt for every field of a type. `existing` supplies defaults when editing.
|
|
25
|
+
// Returns { values, hasSecret } where values are the entered field values.
|
|
26
|
+
async function promptFields(type, existing = {}) {
|
|
27
|
+
const def = typeDef(type);
|
|
28
|
+
if (def.note) process.stdout.write(paint(c.dim, def.note) + '\n');
|
|
29
|
+
|
|
30
|
+
const values = {};
|
|
31
|
+
let hasSecret = false;
|
|
32
|
+
|
|
33
|
+
for (const field of def.fields) {
|
|
34
|
+
const envVal = process.env[field.env];
|
|
35
|
+
if (field.secret) {
|
|
36
|
+
hasSecret = true;
|
|
37
|
+
if (envVal) {
|
|
38
|
+
process.stdout.write(
|
|
39
|
+
paint(c.green, '✓') + ` ${field.label} detected in ${paint(c.dim, '$' + field.env)}\n`
|
|
40
|
+
);
|
|
41
|
+
const useEnv = await tty.confirm(' Use the value from the environment?', { defaultValue: true });
|
|
42
|
+
if (useEnv) {
|
|
43
|
+
// Leave unset so it is read from env at run time (not stored).
|
|
44
|
+
values[field.key] = undefined;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const hadStored = existing[field.key] ? paint(c.dim, ' (press Enter to keep saved value)') : '';
|
|
49
|
+
const entered = await tty.askSecret(` ${field.label}${hadStored}`);
|
|
50
|
+
if (entered) values[field.key] = entered;
|
|
51
|
+
else if (existing[field.key]) values[field.key] = existing[field.key];
|
|
52
|
+
else if (!field.optional) throw new Error(`${field.label} is required`);
|
|
53
|
+
} else {
|
|
54
|
+
const def2 = existing[field.key] || envVal || field.default || '';
|
|
55
|
+
const entered = await tty.ask(` ${field.label}`, { defaultValue: def2 });
|
|
56
|
+
if (entered) values[field.key] = entered;
|
|
57
|
+
else if (!field.optional) throw new Error(`${field.label} is required`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { values, hasSecret };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create or edit a profile interactively and persist it.
|
|
65
|
+
// opts: { name?, type?, editing? } -> returns the saved profile name.
|
|
66
|
+
async function configureProfile(config, opts = {}) {
|
|
67
|
+
const editing = Boolean(opts.name && config.profiles[opts.name]);
|
|
68
|
+
const existing = editing ? config.profiles[opts.name] : {};
|
|
69
|
+
|
|
70
|
+
const type = opts.type || (await pickType(existing.type));
|
|
71
|
+
const { values, hasSecret } = await promptFields(type, existing);
|
|
72
|
+
|
|
73
|
+
const profile = { type };
|
|
74
|
+
for (const [k, v] of Object.entries(values)) {
|
|
75
|
+
if (v !== undefined) profile[k] = v;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Decide whether to persist any secret in plaintext.
|
|
79
|
+
const def = typeDef(type);
|
|
80
|
+
const secretFields = def.fields.filter((f) => f.secret);
|
|
81
|
+
const enteredSecret = secretFields.some((f) => values[f.key] !== undefined);
|
|
82
|
+
|
|
83
|
+
if (enteredSecret) {
|
|
84
|
+
const store = await tty.confirm(
|
|
85
|
+
'Save the secret in ~/.cldz/config.json (plaintext)?',
|
|
86
|
+
{ defaultValue: true }
|
|
87
|
+
);
|
|
88
|
+
if (!store) {
|
|
89
|
+
for (const f of secretFields) delete profile[f.key];
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
paint(c.dim, ` Secret not saved — set $${secretFields.map((f) => f.env).join(' / $')} before running.`) + '\n'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Isolation: keep this profile's Claude session (and its credential) separate
|
|
97
|
+
// from your main ~/.claude login. Recommended so the profile's credential is
|
|
98
|
+
// the one actually used.
|
|
99
|
+
const isolate = await tty.confirm(
|
|
100
|
+
'Keep this profile isolated from your main ~/.claude login (recommended)?',
|
|
101
|
+
{ defaultValue: existing.isolate !== false }
|
|
102
|
+
);
|
|
103
|
+
if (isolate) delete profile.isolate;
|
|
104
|
+
else profile.isolate = false;
|
|
105
|
+
|
|
106
|
+
let name = opts.name;
|
|
107
|
+
if (!name) {
|
|
108
|
+
const suggested = suggestName(config, type);
|
|
109
|
+
name = await tty.ask('Profile name', { defaultValue: suggested });
|
|
110
|
+
}
|
|
111
|
+
if (!name) throw new Error('a profile name is required');
|
|
112
|
+
|
|
113
|
+
config.profiles[name] = profile;
|
|
114
|
+
|
|
115
|
+
// Default selection.
|
|
116
|
+
const onlyProfile = Object.keys(config.profiles).length === 1;
|
|
117
|
+
if (onlyProfile) {
|
|
118
|
+
config.defaultProfile = name;
|
|
119
|
+
} else if (config.defaultProfile !== name) {
|
|
120
|
+
const makeDefault = await tty.confirm(`Make "${name}" the default profile?`, {
|
|
121
|
+
defaultValue: !editing,
|
|
122
|
+
});
|
|
123
|
+
if (makeDefault) config.defaultProfile = name;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { configureProfile, pickType, promptFields, suggestName };
|