xclaude-launcher 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 placeholder
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 ADDED
@@ -0,0 +1,108 @@
1
+ # Claude Launcher
2
+
3
+ [English](./README.md) | [中文](./README.zh-CN.md)
4
+
5
+ `xclaude` solves the problem of switching Claude Code between multiple environment sources.
6
+
7
+ If you work with more than one Claude setup, such as:
8
+
9
+ - the default official Claude service
10
+ - a proxy or gateway endpoint
11
+ - different API base URLs for work and personal use
12
+ - different model defaults for different tasks
13
+ - different auth tokens across environments
14
+
15
+ then repeatedly exporting environment variables by hand is tedious and error-prone.
16
+
17
+ `xclaude` lets you save each setup as a reusable profile, then launch `claude` with the right environment in one step.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install -g xclaude-launcher
23
+ ```
24
+
25
+ ## Prerequisites
26
+
27
+ `xclaude` launches the local `claude` command, so Claude Code must already be installed and available in your `PATH`.
28
+
29
+ ## QuickStart
30
+
31
+ ```bash
32
+ # 1. Add your first profile
33
+ xclaude config add
34
+ ```
35
+
36
+ When creating a profile, you will be prompted for:
37
+
38
+ - `Profile name`: the label shown in the launcher
39
+ - `ANTHROPIC_AUTH_TOKEN`: your Claude auth token
40
+ - `ANTHROPIC_BASE_URL`: optional custom API base URL
41
+ - `ANTHROPIC_MODEL`: optional default model
42
+ - `ANTHROPIC_DEFAULT_HAIKU_MODEL`: optional Haiku override
43
+ - `ANTHROPIC_DEFAULT_SONNET_MODEL`: optional Sonnet override
44
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL`: optional Opus override
45
+ - `CLAUDE_CODE_SUBAGENT_MODEL`: optional subagent model override
46
+ - Additional custom ENV vars if needed
47
+
48
+ You can leave optional fields empty and only set the values you actually use.
49
+
50
+ ```bash
51
+ # 2. Launch with the interactive picker
52
+ xclaude
53
+
54
+ # 3. Or launch a specific profile directly
55
+ xclaude --profile my-profile
56
+ ```
57
+
58
+ If you have not created any profiles yet, start with `xclaude config add`.
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ xclaude
64
+ xclaude --profile my-profile
65
+ xclaude --list
66
+ xclaude config
67
+ xclaude config path
68
+ ```
69
+
70
+ ## What it does
71
+
72
+ - `xclaude` opens an interactive profile picker and launches `claude` with the selected profile environment.
73
+ - `xclaude config` opens the interactive profile manager.
74
+
75
+ ## Config file
76
+
77
+ Profiles are stored in:
78
+
79
+ ```text
80
+ ~/.claude-launcher/config.json
81
+ ```
82
+
83
+ ## Built-in environment variables
84
+
85
+ `xclaude config` prompts for these first-class variables:
86
+
87
+ - `ANTHROPIC_AUTH_TOKEN`
88
+ - `ANTHROPIC_BASE_URL`
89
+ - `ANTHROPIC_MODEL`
90
+ - `ANTHROPIC_DEFAULT_HAIKU_MODEL`
91
+ - `ANTHROPIC_DEFAULT_SONNET_MODEL`
92
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL`
93
+ - `CLAUDE_CODE_SUBAGENT_MODEL`
94
+
95
+ You can also add any number of custom environment variables to each profile.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ npm install
101
+ npm run build
102
+ npm run start -- --help
103
+ ```
104
+
105
+ ## Links
106
+
107
+ - Repository: https://github.com/placeholder/xclaude
108
+ - Issues: https://github.com/placeholder/xclaude/issues
@@ -0,0 +1,117 @@
1
+ # Claude Launcher
2
+
3
+ [English](./README.md) | [中文](./README.zh-CN.md)
4
+
5
+ `xclaude` 用来解决 Claude Code 在多个环境源之间切换麻烦的问题。
6
+
7
+ 如果你平时会在多个 Claude 配置之间来回切换,比如:
8
+
9
+ - 官方默认 Claude 服务
10
+ - 公司或团队内部代理 / 网关
11
+ - 不同的 `ANTHROPIC_BASE_URL`
12
+ - 不同环境下使用不同的 token
13
+ - 不同场景下使用不同的默认模型
14
+
15
+ 那么每次手动切换环境变量会很繁琐,也很容易出错。
16
+
17
+ `xclaude` 可以把这些配置保存成可复用的 profile,然后在启动 `claude` 时一键带上对应环境变量。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ npm install -g xclaude-launcher
23
+ ```
24
+
25
+ ## 前置要求
26
+
27
+ `xclaude` 本身不会替代 Claude Code,它只是负责按指定 profile 启动本地的 `claude` 命令。
28
+
29
+ 所以在使用前,需要先确保:
30
+
31
+ - 已经安装 Claude Code
32
+ - `claude` 命令已经在你的 `PATH` 中可用
33
+
34
+ ## QuickStart
35
+
36
+ ```bash
37
+ # 1. 新增第一个 profile
38
+ xclaude config add
39
+ ```
40
+
41
+ 创建 profile 时,会依次提示你填写这些信息:
42
+
43
+ - `Profile name`:这个 profile 在启动器里的显示名称
44
+ - `ANTHROPIC_AUTH_TOKEN`:你的 Claude 鉴权 token
45
+ - `ANTHROPIC_BASE_URL`:可选,自定义 API 地址
46
+ - `ANTHROPIC_MODEL`:可选,默认模型
47
+ - `ANTHROPIC_DEFAULT_HAIKU_MODEL`:可选,Haiku 默认模型覆盖
48
+ - `ANTHROPIC_DEFAULT_SONNET_MODEL`:可选,Sonnet 默认模型覆盖
49
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL`:可选,Opus 默认模型覆盖
50
+ - `CLAUDE_CODE_SUBAGENT_MODEL`:可选,subagent 默认模型覆盖
51
+ - 其他自定义 ENV 变量:按需添加
52
+
53
+ 如果某些字段你暂时不需要,可以直接留空。
54
+
55
+ ```bash
56
+ # 2. 使用交互方式选择 profile 并启动
57
+ xclaude
58
+
59
+ # 3. 或者直接指定 profile 启动
60
+ xclaude --profile my-profile
61
+ ```
62
+
63
+ 如果你还没有创建任何 profile,建议先执行:
64
+
65
+ ```bash
66
+ xclaude config add
67
+ ```
68
+
69
+ ## 用法
70
+
71
+ ```bash
72
+ xclaude
73
+ xclaude --profile my-profile
74
+ xclaude --list
75
+ xclaude config
76
+ xclaude config path
77
+ ```
78
+
79
+ ## 这个工具会做什么
80
+
81
+ - `xclaude` 会打开一个交互式 profile 选择器,并使用所选 profile 的环境变量启动 `claude`
82
+ - `xclaude config` 会打开交互式 profile 管理界面
83
+
84
+ ## 配置文件位置
85
+
86
+ profile 保存在:
87
+
88
+ ```text
89
+ ~/.claude-launcher/config.json
90
+ ```
91
+
92
+ ## 内置支持的环境变量
93
+
94
+ 执行 `xclaude config` 时,会优先提示这些一等字段:
95
+
96
+ - `ANTHROPIC_AUTH_TOKEN`
97
+ - `ANTHROPIC_BASE_URL`
98
+ - `ANTHROPIC_MODEL`
99
+ - `ANTHROPIC_DEFAULT_HAIKU_MODEL`
100
+ - `ANTHROPIC_DEFAULT_SONNET_MODEL`
101
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL`
102
+ - `CLAUDE_CODE_SUBAGENT_MODEL`
103
+
104
+ 除此之外,你也可以为每个 profile 添加任意数量的自定义环境变量。
105
+
106
+ ## 开发
107
+
108
+ ```bash
109
+ npm install
110
+ npm run build
111
+ npm run start -- --help
112
+ ```
113
+
114
+ ## 链接
115
+
116
+ - Repository: https://github.com/placeholder/xclaude
117
+ - Issues: https://github.com/placeholder/xclaude/issues
@@ -0,0 +1,31 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { CliError } from '../utils/errors.js';
3
+ import { getConfigDir, getConfigPath } from '../utils/paths.js';
4
+ const DEFAULT_CONFIG = {
5
+ version: 1,
6
+ profiles: [],
7
+ };
8
+ export class FileConfigRepository {
9
+ async load() {
10
+ const filePath = getConfigPath();
11
+ try {
12
+ const content = await readFile(filePath, 'utf8');
13
+ const parsed = JSON.parse(content);
14
+ return {
15
+ version: parsed.version ?? 1,
16
+ profiles: parsed.profiles ?? [],
17
+ lastUsedProfileId: parsed.lastUsedProfileId,
18
+ };
19
+ }
20
+ catch (error) {
21
+ if (error.code === 'ENOENT') {
22
+ return structuredClone(DEFAULT_CONFIG);
23
+ }
24
+ throw new CliError(`Failed to read config: ${filePath}`);
25
+ }
26
+ }
27
+ async save(config) {
28
+ await mkdir(getConfigDir(), { recursive: true });
29
+ await writeFile(getConfigPath(), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
30
+ }
31
+ }
@@ -0,0 +1,15 @@
1
+ import { spawn } from 'node:child_process';
2
+ export class ProcessRunner {
3
+ run(command, args, env) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(command, args, {
6
+ env,
7
+ stdio: 'inherit',
8
+ });
9
+ child.on('error', reject);
10
+ child.on('exit', (code) => {
11
+ resolve(code ?? 0);
12
+ });
13
+ });
14
+ }
15
+ }
@@ -0,0 +1,107 @@
1
+ import { ConfigService } from '../services/config-service.js';
2
+ import { PromptService } from '../services/prompt-service.js';
3
+ import { getConfigPath } from '../utils/paths.js';
4
+ import { CliError } from '../utils/errors.js';
5
+ export async function runConfigCommand(action) {
6
+ const configService = new ConfigService();
7
+ const promptService = new PromptService();
8
+ while (true) {
9
+ const nextAction = normalizeAction(action) ?? (await promptService.chooseConfigAction());
10
+ action = undefined;
11
+ if (nextAction === 'path') {
12
+ console.log(getConfigPath());
13
+ continue;
14
+ }
15
+ if (nextAction === 'exit') {
16
+ return 0;
17
+ }
18
+ if (nextAction === 'list') {
19
+ while (true) {
20
+ const config = await configService.getConfig();
21
+ promptService.printProfiles(config.profiles, config.lastUsedProfileId);
22
+ if (config.profiles.length === 0) {
23
+ break;
24
+ }
25
+ const profile = await promptService.chooseProfileOrBackOrExit(config.profiles, config.lastUsedProfileId);
26
+ if (profile === 'exit') {
27
+ return 0;
28
+ }
29
+ if (profile === 'back') {
30
+ break;
31
+ }
32
+ const profileAction = await promptService.chooseProfileAction();
33
+ if (profileAction === 'exit') {
34
+ return 0;
35
+ }
36
+ if (profileAction === 'back') {
37
+ continue;
38
+ }
39
+ if (profileAction === 'edit') {
40
+ const input = await promptService.promptProfileInput(profile);
41
+ if (input === 'exit') {
42
+ return 0;
43
+ }
44
+ if (input === 'back') {
45
+ continue;
46
+ }
47
+ const updated = await configService.updateProfile(profile.id, input);
48
+ console.log(`Updated profile: ${updated.name}`);
49
+ continue;
50
+ }
51
+ if (await promptService.confirmRemoveProfile(profile)) {
52
+ const removed = await configService.removeProfile(profile.id);
53
+ console.log(`Removed profile: ${removed.name}`);
54
+ }
55
+ }
56
+ continue;
57
+ }
58
+ if (nextAction === 'add') {
59
+ while (true) {
60
+ const input = await promptService.promptProfileInput();
61
+ if (input === 'exit') {
62
+ return 0;
63
+ }
64
+ if (input === 'back') {
65
+ break;
66
+ }
67
+ const profile = await configService.addProfile(input);
68
+ console.log(`Added profile: ${profile.name}`);
69
+ return 0;
70
+ }
71
+ continue;
72
+ }
73
+ if (nextAction === 'edit') {
74
+ while (true) {
75
+ const config = await configService.getConfig();
76
+ const profile = await promptService.chooseProfileOrBackOrExit(config.profiles, config.lastUsedProfileId);
77
+ if (profile === 'exit') {
78
+ return 0;
79
+ }
80
+ if (profile === 'back') {
81
+ break;
82
+ }
83
+ const input = await promptService.promptProfileInput(profile);
84
+ if (input === 'exit') {
85
+ return 0;
86
+ }
87
+ if (input === 'back') {
88
+ continue;
89
+ }
90
+ const updated = await configService.updateProfile(profile.id, input);
91
+ console.log(`Updated profile: ${updated.name}`);
92
+ continue;
93
+ }
94
+ continue;
95
+ }
96
+ throw new CliError(`Unsupported config action: ${action}`);
97
+ }
98
+ }
99
+ function normalizeAction(action) {
100
+ if (!action) {
101
+ return undefined;
102
+ }
103
+ if (action === 'list' || action === 'add' || action === 'edit' || action === 'path') {
104
+ return action;
105
+ }
106
+ throw new CliError(`Unsupported config action: ${action}`);
107
+ }
@@ -0,0 +1,19 @@
1
+ import { ConfigService } from '../services/config-service.js';
2
+ import { PromptService } from '../services/prompt-service.js';
3
+ export async function runClaudeCommand(options) {
4
+ const configService = new ConfigService();
5
+ const promptService = new PromptService();
6
+ const config = await configService.getConfig();
7
+ const sortedProfiles = configService.sortProfilesByLastUsed(config.profiles, config.lastUsedProfileId);
8
+ if (options.list) {
9
+ promptService.printProfiles(sortedProfiles, config.lastUsedProfileId);
10
+ return 0;
11
+ }
12
+ const profile = options.profile
13
+ ? await configService.getProfileByIdOrName(options.profile)
14
+ : await promptService.chooseProfileForLaunch(sortedProfiles, config.lastUsedProfileId);
15
+ await configService.markLastUsed(profile.id);
16
+ const { ClaudeLauncherService } = await import('../services/claude-launcher.js');
17
+ const launcher = new ClaudeLauncherService();
18
+ return launcher.launch(profile, options.claudeArgs);
19
+ }
package/dist/index.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { runClaudeCommand } from './commands/run-claude.js';
3
+ import { runConfigCommand } from './commands/config.js';
4
+ import { CliError } from './utils/errors.js';
5
+ async function main() {
6
+ const args = process.argv.slice(2);
7
+ const [command, ...rest] = args;
8
+ if (command === '--help' || command === '-h') {
9
+ printHelp();
10
+ return;
11
+ }
12
+ if (command === 'config') {
13
+ const code = await runConfigCommand(rest[0]);
14
+ process.exitCode = code;
15
+ return;
16
+ }
17
+ const code = await runClaudeCommand(parseRunClaudeOptions(args));
18
+ process.exitCode = code;
19
+ }
20
+ function parseRunClaudeOptions(args) {
21
+ const options = {
22
+ claudeArgs: [],
23
+ };
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const current = args[index];
26
+ if (current === '--profile') {
27
+ const value = args[index + 1];
28
+ if (!value) {
29
+ throw new CliError('--profile requires a value');
30
+ }
31
+ options.profile = value;
32
+ index += 1;
33
+ continue;
34
+ }
35
+ if (current === '--list') {
36
+ options.list = true;
37
+ continue;
38
+ }
39
+ options.claudeArgs.push(current);
40
+ }
41
+ return options;
42
+ }
43
+ function printHelp() {
44
+ console.log(`xclaude - Claude launcher\n\nUsage:\n xclaude [--profile <name-or-id>] [--list] [claude args...]\n xclaude config [list|add|edit]\n`);
45
+ }
46
+ main().catch((error) => {
47
+ const message = error instanceof Error ? error.message : 'Unknown error';
48
+ console.error(message);
49
+ process.exitCode = 1;
50
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { ProcessRunner } from '../adapters/process-runner.js';
2
+ import { mergeEnv } from '../utils/env.js';
3
+ import { CliError } from '../utils/errors.js';
4
+ export class ClaudeLauncherService {
5
+ processRunner;
6
+ constructor(processRunner = new ProcessRunner()) {
7
+ this.processRunner = processRunner;
8
+ }
9
+ async launch(profile, extraArgs = []) {
10
+ try {
11
+ return await this.processRunner.run(profile.command, [...profile.args, ...extraArgs], mergeEnv(profile.env));
12
+ }
13
+ catch (error) {
14
+ const errno = error;
15
+ if (errno.code === 'ENOENT') {
16
+ throw new CliError('Could not find the claude command. Make sure it is installed and available in PATH');
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,99 @@
1
+ import { FileConfigRepository } from '../adapters/file-config-repo.js';
2
+ import { buildProfileEnv } from '../utils/env.js';
3
+ import { CliError } from '../utils/errors.js';
4
+ export class ConfigService {
5
+ repository;
6
+ constructor(repository = new FileConfigRepository()) {
7
+ this.repository = repository;
8
+ }
9
+ async getConfig() {
10
+ return this.repository.load();
11
+ }
12
+ async listProfiles() {
13
+ const config = await this.repository.load();
14
+ return config.profiles;
15
+ }
16
+ sortProfilesByLastUsed(profiles, lastUsedProfileId) {
17
+ if (!lastUsedProfileId) {
18
+ return [...profiles];
19
+ }
20
+ const sortedProfiles = [...profiles];
21
+ const index = sortedProfiles.findIndex((profile) => profile.id === lastUsedProfileId);
22
+ if (index <= 0) {
23
+ return sortedProfiles;
24
+ }
25
+ const [lastUsedProfile] = sortedProfiles.splice(index, 1);
26
+ sortedProfiles.unshift(lastUsedProfile);
27
+ return sortedProfiles;
28
+ }
29
+ async getProfileByIdOrName(idOrName) {
30
+ const config = await this.repository.load();
31
+ const profile = config.profiles.find((item) => item.id === idOrName || item.name === idOrName);
32
+ if (!profile) {
33
+ throw new CliError(`Profile not found: ${idOrName}`);
34
+ }
35
+ return profile;
36
+ }
37
+ async addProfile(input) {
38
+ const config = await this.repository.load();
39
+ const id = this.createProfileId(input.name);
40
+ if (config.profiles.some((profile) => profile.id === id || profile.name === input.name)) {
41
+ throw new CliError(`Profile already exists: ${input.name}`);
42
+ }
43
+ const profile = {
44
+ id,
45
+ name: input.name,
46
+ command: 'claude',
47
+ args: [],
48
+ env: buildProfileEnv(input),
49
+ };
50
+ config.profiles.push(profile);
51
+ await this.repository.save(config);
52
+ return profile;
53
+ }
54
+ async updateProfile(profileId, input) {
55
+ const config = await this.repository.load();
56
+ const profile = config.profiles.find((item) => item.id === profileId);
57
+ if (!profile) {
58
+ throw new CliError(`Profile not found: ${profileId}`);
59
+ }
60
+ const nextId = this.createProfileId(input.name);
61
+ const duplicate = config.profiles.find((item) => item.id !== profileId && (item.id === nextId || item.name === input.name));
62
+ if (duplicate) {
63
+ throw new CliError(`Profile name conflict: ${input.name}`);
64
+ }
65
+ profile.id = nextId;
66
+ profile.name = input.name;
67
+ profile.env = buildProfileEnv(input);
68
+ if (config.lastUsedProfileId === profileId) {
69
+ config.lastUsedProfileId = nextId;
70
+ }
71
+ await this.repository.save(config);
72
+ return profile;
73
+ }
74
+ async markLastUsed(profileId) {
75
+ const config = await this.repository.load();
76
+ config.lastUsedProfileId = profileId;
77
+ await this.repository.save(config);
78
+ }
79
+ async removeProfile(profileId) {
80
+ const config = await this.repository.load();
81
+ const index = config.profiles.findIndex((item) => item.id === profileId);
82
+ if (index === -1) {
83
+ throw new CliError(`Profile not found: ${profileId}`);
84
+ }
85
+ const [removedProfile] = config.profiles.splice(index, 1);
86
+ if (config.lastUsedProfileId === profileId) {
87
+ config.lastUsedProfileId = config.profiles[0]?.id;
88
+ }
89
+ await this.repository.save(config);
90
+ return removedProfile;
91
+ }
92
+ createProfileId(name) {
93
+ return name
94
+ .trim()
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, '-')
97
+ .replace(/(^-|-$)/g, '');
98
+ }
99
+ }
@@ -0,0 +1,238 @@
1
+ import { confirm, input, select } from '@inquirer/prompts';
2
+ import { maskEnvValue, validateEnvKey } from '../utils/env.js';
3
+ import { CliError } from '../utils/errors.js';
4
+ export class PromptService {
5
+ async chooseProfile(profiles, lastUsedProfileId) {
6
+ if (profiles.length === 0) {
7
+ throw new CliError('No profiles available yet. Run xclaude config add first');
8
+ }
9
+ const answer = await select({
10
+ message: 'Choose a profile',
11
+ choices: profiles.map((profile) => ({
12
+ name: this.formatProfileLabel(profile, lastUsedProfileId),
13
+ value: profile.id,
14
+ description: this.describeProfile(profile),
15
+ })),
16
+ });
17
+ const profile = profiles.find((item) => item.id === answer);
18
+ if (!profile) {
19
+ throw new CliError('Selected profile does not exist');
20
+ }
21
+ return profile;
22
+ }
23
+ async chooseProfileForLaunch(profiles, lastUsedProfileId) {
24
+ if (profiles.length === 0) {
25
+ throw new CliError('No profiles available yet. Run xclaude config add first');
26
+ }
27
+ const answer = await select({
28
+ message: 'Launch profile',
29
+ choices: [
30
+ ...profiles.map((profile) => ({
31
+ name: this.formatProfileLabel(profile, lastUsedProfileId),
32
+ value: profile.id,
33
+ })),
34
+ {
35
+ name: 'Exit',
36
+ value: '__exit__',
37
+ description: 'Exit without launching Claude',
38
+ },
39
+ ],
40
+ });
41
+ if (answer === '__exit__') {
42
+ throw new CliError('Launch cancelled');
43
+ }
44
+ const profile = profiles.find((item) => item.id === answer);
45
+ if (!profile) {
46
+ throw new CliError('Selected profile does not exist');
47
+ }
48
+ return profile;
49
+ }
50
+ async chooseConfigAction() {
51
+ return select({
52
+ message: 'Choose a config action',
53
+ choices: [
54
+ { name: 'List profiles', value: 'list' },
55
+ { name: 'Add profile', value: 'add' },
56
+ { name: 'Edit profile', value: 'edit' },
57
+ { name: 'Show config path', value: 'path' },
58
+ { name: 'Exit', value: 'exit' },
59
+ ],
60
+ });
61
+ }
62
+ async confirmLaunchProfile(profile, extraArgs = []) {
63
+ console.log(`About to launch with profile: ${profile.name} (${profile.id})`);
64
+ for (const [key, value] of Object.entries(profile.env)) {
65
+ console.log(` ${key}=${maskEnvValue(key, value)}`);
66
+ }
67
+ console.log(` Claude args=${extraArgs.length > 0 ? extraArgs.join(' ') : '(none)'}`);
68
+ return confirm({
69
+ message: 'Launch Claude?',
70
+ default: true,
71
+ });
72
+ }
73
+ async chooseProfileOrBackOrExit(profiles, lastUsedProfileId) {
74
+ if (profiles.length === 0) {
75
+ return 'back';
76
+ }
77
+ const answer = await select({
78
+ message: 'Choose a profile',
79
+ choices: [
80
+ ...profiles.map((profile) => ({
81
+ name: this.formatProfileLabel(profile, lastUsedProfileId),
82
+ value: profile.id,
83
+ description: this.describeProfile(profile),
84
+ })),
85
+ {
86
+ name: 'Back',
87
+ value: '__back__',
88
+ description: 'Return to the previous menu',
89
+ },
90
+ {
91
+ name: 'Exit',
92
+ value: '__exit__',
93
+ description: 'Exit config',
94
+ },
95
+ ],
96
+ });
97
+ if (answer === '__back__') {
98
+ return 'back';
99
+ }
100
+ if (answer === '__exit__') {
101
+ return 'exit';
102
+ }
103
+ return profiles.find((item) => item.id === answer) ?? 'back';
104
+ }
105
+ async chooseProfileAction() {
106
+ return select({
107
+ message: 'Choose an action',
108
+ choices: [
109
+ { name: 'Edit', value: 'edit' },
110
+ { name: 'Remove', value: 'remove' },
111
+ { name: 'Back', value: 'back' },
112
+ { name: 'Exit', value: 'exit' },
113
+ ],
114
+ });
115
+ }
116
+ async confirmRemoveProfile(profile) {
117
+ return confirm({
118
+ message: `Remove profile ${profile.name}?`,
119
+ default: false,
120
+ });
121
+ }
122
+ async promptProfileInput(existing) {
123
+ const name = await input({
124
+ message: 'Profile name',
125
+ default: existing?.name,
126
+ validate: (value) => (value.trim() ? true : 'Profile name is required'),
127
+ });
128
+ const anthropicAuthToken = await input({
129
+ message: 'ANTHROPIC_AUTH_TOKEN',
130
+ default: existing?.env.ANTHROPIC_AUTH_TOKEN,
131
+ });
132
+ const anthropicBaseUrl = await input({
133
+ message: 'ANTHROPIC_BASE_URL',
134
+ default: existing?.env.ANTHROPIC_BASE_URL,
135
+ });
136
+ const anthropicModel = await input({
137
+ message: 'ANTHROPIC_MODEL',
138
+ default: existing?.env.ANTHROPIC_MODEL,
139
+ });
140
+ const anthropicDefaultHaikuModel = await input({
141
+ message: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
142
+ default: existing?.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
143
+ });
144
+ const anthropicDefaultSonnetModel = await input({
145
+ message: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
146
+ default: existing?.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
147
+ });
148
+ const anthropicDefaultOpusModel = await input({
149
+ message: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
150
+ default: existing?.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
151
+ });
152
+ const claudeCodeSubagentModel = await input({
153
+ message: 'CLAUDE_CODE_SUBAGENT_MODEL',
154
+ default: existing?.env.CLAUDE_CODE_SUBAGENT_MODEL,
155
+ });
156
+ const extraEnv = await this.promptExtraEnv(existing?.env ?? {});
157
+ return {
158
+ name: name.trim(),
159
+ anthropicAuthToken: anthropicAuthToken.trim() || undefined,
160
+ anthropicBaseUrl: anthropicBaseUrl.trim() || undefined,
161
+ anthropicModel: anthropicModel.trim() || undefined,
162
+ anthropicDefaultHaikuModel: anthropicDefaultHaikuModel.trim() || undefined,
163
+ anthropicDefaultSonnetModel: anthropicDefaultSonnetModel.trim() || undefined,
164
+ anthropicDefaultOpusModel: anthropicDefaultOpusModel.trim() || undefined,
165
+ claudeCodeSubagentModel: claudeCodeSubagentModel.trim() || undefined,
166
+ extraEnv,
167
+ };
168
+ }
169
+ printProfiles(profiles, lastUsedProfileId) {
170
+ if (profiles.length === 0) {
171
+ console.log('No profiles');
172
+ return;
173
+ }
174
+ for (const profile of profiles) {
175
+ const markers = [];
176
+ if (profile.id === lastUsedProfileId) {
177
+ markers.push('recent');
178
+ }
179
+ const marker = markers.length > 0 ? ` [${markers.join(' / ')}]` : '';
180
+ console.log(`- ${profile.name} (${profile.id})${marker}`);
181
+ for (const [key, value] of Object.entries(profile.env)) {
182
+ console.log(` ${key}=${maskEnvValue(key, value)}`);
183
+ }
184
+ }
185
+ }
186
+ async promptExtraEnv(existingEnv) {
187
+ const extraEnv = {};
188
+ const presetEntries = Object.entries(existingEnv).filter(([key]) => key !== 'ANTHROPIC_AUTH_TOKEN' &&
189
+ key !== 'ANTHROPIC_BASE_URL' &&
190
+ key !== 'ANTHROPIC_MODEL' &&
191
+ key !== 'ANTHROPIC_DEFAULT_HAIKU_MODEL' &&
192
+ key !== 'ANTHROPIC_DEFAULT_SONNET_MODEL' &&
193
+ key !== 'ANTHROPIC_DEFAULT_OPUS_MODEL' &&
194
+ key !== 'CLAUDE_CODE_SUBAGENT_MODEL');
195
+ for (const [key, value] of presetEntries) {
196
+ const keep = await confirm({
197
+ message: `Keep ${key}=${maskEnvValue(key, value)}?`,
198
+ default: true,
199
+ });
200
+ if (keep) {
201
+ extraEnv[key] = value;
202
+ }
203
+ }
204
+ while (await confirm({
205
+ message: 'Add a custom ENV?',
206
+ default: false,
207
+ })) {
208
+ const key = (await input({
209
+ message: 'ENV name',
210
+ validate: (value) => {
211
+ try {
212
+ validateEnvKey(value.trim());
213
+ return true;
214
+ }
215
+ catch (error) {
216
+ return error.message;
217
+ }
218
+ },
219
+ })).trim();
220
+ const value = await input({
221
+ message: `ENV value (${key})`,
222
+ });
223
+ extraEnv[key] = value;
224
+ }
225
+ return extraEnv;
226
+ }
227
+ formatProfileLabel(profile, lastUsedProfileId) {
228
+ const markers = [];
229
+ if (profile.id === lastUsedProfileId) {
230
+ markers.push('recent');
231
+ }
232
+ return markers.length > 0 ? `${profile.name} (${markers.join(', ')})` : profile.name;
233
+ }
234
+ describeProfile(profile) {
235
+ const pairs = Object.entries(profile.env).map(([key, value]) => `${key}=${maskEnvValue(key, value)}`);
236
+ return pairs.length > 0 ? pairs.join('\n') : 'No ENV';
237
+ }
238
+ }
@@ -0,0 +1,45 @@
1
+ import { CliError } from './errors.js';
2
+ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
3
+ export function validateEnvKey(key) {
4
+ if (!ENV_KEY_PATTERN.test(key)) {
5
+ throw new CliError(`Invalid ENV name: ${key}`);
6
+ }
7
+ }
8
+ export function mergeEnv(profileEnv) {
9
+ return {
10
+ ...process.env,
11
+ ...profileEnv,
12
+ };
13
+ }
14
+ export function maskEnvValue(_key, value) {
15
+ return value;
16
+ }
17
+ export function buildProfileEnv(input) {
18
+ const env = {};
19
+ if (input.anthropicAuthToken) {
20
+ env.ANTHROPIC_AUTH_TOKEN = input.anthropicAuthToken;
21
+ }
22
+ if (input.anthropicBaseUrl) {
23
+ env.ANTHROPIC_BASE_URL = input.anthropicBaseUrl;
24
+ }
25
+ if (input.anthropicModel) {
26
+ env.ANTHROPIC_MODEL = input.anthropicModel;
27
+ }
28
+ if (input.anthropicDefaultHaikuModel) {
29
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = input.anthropicDefaultHaikuModel;
30
+ }
31
+ if (input.anthropicDefaultSonnetModel) {
32
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = input.anthropicDefaultSonnetModel;
33
+ }
34
+ if (input.anthropicDefaultOpusModel) {
35
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = input.anthropicDefaultOpusModel;
36
+ }
37
+ if (input.claudeCodeSubagentModel) {
38
+ env.CLAUDE_CODE_SUBAGENT_MODEL = input.claudeCodeSubagentModel;
39
+ }
40
+ for (const [key, value] of Object.entries(input.extraEnv)) {
41
+ validateEnvKey(key);
42
+ env[key] = value;
43
+ }
44
+ return env;
45
+ }
@@ -0,0 +1,6 @@
1
+ export class CliError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'CliError';
5
+ }
6
+ }
@@ -0,0 +1,8 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ export function getConfigDir() {
4
+ return path.join(os.homedir(), '.claude-launcher');
5
+ }
6
+ export function getConfigPath() {
7
+ return path.join(getConfigDir(), 'config.json');
8
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "xclaude-launcher",
3
+ "version": "0.1.0",
4
+ "description": "Launch Claude Code with reusable environment profiles.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Jango26/claude-launcher.git"
9
+ },
10
+ "homepage": "https://github.com/Jango26/claude-launcher",
11
+ "bugs": {
12
+ "url": "https://github.com/Jango26/claude-launcher/issues"
13
+ },
14
+ "keywords": [
15
+ "claude",
16
+ "claude-code",
17
+ "cli",
18
+ "launcher",
19
+ "profiles"
20
+ ],
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "type": "module",
27
+ "bin": {
28
+ "xclaude": "./dist/index.js"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
32
+ "dev": "tsc --watch",
33
+ "start": "node dist/index.js"
34
+ },
35
+ "dependencies": {
36
+ "@inquirer/prompts": "^7.5.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^24.0.1",
40
+ "tsx": "^4.19.4",
41
+ "typescript": "^5.8.3"
42
+ }
43
+ }