@aeriondyseti/claude-profiles 0.1.0-dev.9
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 +156 -0
- package/package.json +39 -0
- package/src/cli.ts +108 -0
- package/src/commands/bind.ts +54 -0
- package/src/commands/clone.ts +45 -0
- package/src/commands/create.ts +80 -0
- package/src/commands/delete.ts +37 -0
- package/src/commands/edit.ts +21 -0
- package/src/commands/list.ts +29 -0
- package/src/commands/run.ts +44 -0
- package/src/commands/shared.ts +45 -0
- package/src/commands/switch.ts +19 -0
- package/src/commands/unbind.ts +27 -0
- package/src/detect.test.ts +174 -0
- package/src/detect.ts +44 -0
- package/src/index.ts +72 -0
- package/src/profiles.test.ts +45 -0
- package/src/profiles.ts +174 -0
- package/src/utils.test.ts +134 -0
- package/src/utils.ts +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# claude-profiles
|
|
2
|
+
|
|
3
|
+
CLI and interactive TUI for managing multiple [Claude Code](https://docs.anthropic.com/en/docs/claude-code) configuration profiles.
|
|
4
|
+
|
|
5
|
+
Each profile is an isolated config directory (`~/.claude-<name>`) with its own `settings.json`, commands, hooks, agents, skills, and output styles. Switch between profiles by setting the `CLAUDE_CONFIG_DIR` environment variable.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
Run directly with npx (no install required):
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx @aeriondyseti/claude-profiles
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This launches the interactive TUI. You can also run specific commands directly:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @aeriondyseti/claude-profiles list
|
|
19
|
+
npx @aeriondyseti/claude-profiles create work
|
|
20
|
+
npx @aeriondyseti/claude-profiles run work -- -p "hello"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# Global install (provides the `claude-profiles` command)
|
|
27
|
+
npm install -g @aeriondyseti/claude-profiles
|
|
28
|
+
|
|
29
|
+
# Or run without installing
|
|
30
|
+
npx @aeriondyseti/claude-profiles
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **Note:** This tool requires [Bun](https://bun.sh) as its runtime.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
| Command | Aliases | Description |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| *(none)* | | Launch interactive TUI |
|
|
40
|
+
| `list` | `ls` | List all profiles and their status |
|
|
41
|
+
| `create [name]` | `new` | Create a new profile (optionally copy settings from an existing one) |
|
|
42
|
+
| `edit [name]` | | Open a profile's `settings.json` in your `$EDITOR` |
|
|
43
|
+
| `clone [source] [name]` | `cp` | Clone a profile's configuration (settings, commands, hooks, agents, skills, output styles) |
|
|
44
|
+
| `delete [name]` | `rm` | Delete a profile (with confirmation) |
|
|
45
|
+
| `switch [name]` | `use` | Print the `export` command and shell alias to activate a profile |
|
|
46
|
+
| `run [name] [-- args]` | `exec` | Launch `claude` with a specific profile's config dir |
|
|
47
|
+
| `bind [name] [path]` | | Bind a profile to a directory via `.claude-profile` |
|
|
48
|
+
| `unbind [path]` | | Remove a `.claude-profile` binding from a directory |
|
|
49
|
+
|
|
50
|
+
All commands accept an optional profile name argument. If omitted, an interactive prompt is shown.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Creating a profile
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
claude-profiles create work
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This creates `~/.claude-work/` with a default `settings.json` that includes a session-start hook displaying the active profile name.
|
|
61
|
+
|
|
62
|
+
During interactive creation, you can optionally copy settings from an existing profile.
|
|
63
|
+
|
|
64
|
+
### Running Claude with a profile
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
# Via the run command
|
|
68
|
+
claude-profiles run work
|
|
69
|
+
|
|
70
|
+
# Pass extra arguments to Claude after --
|
|
71
|
+
claude-profiles run work -- -p "summarize this file"
|
|
72
|
+
|
|
73
|
+
# Or manually set the environment variable
|
|
74
|
+
export CLAUDE_CONFIG_DIR=~/.claude-work
|
|
75
|
+
claude
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Cloning a profile
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
claude-profiles clone work staging
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Clones configuration files only — `settings.json`, `CLAUDE.md`, `commands/`, `hooks/`, `agents/`, `skills/`, and `output-styles/`. Auth state and session history are **not** copied.
|
|
85
|
+
|
|
86
|
+
### Directory-aware profiles
|
|
87
|
+
|
|
88
|
+
You can bind a profile to a directory so that `claude-profiles run` automatically uses it:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
# Bind the "work" profile to the current directory
|
|
92
|
+
claude-profiles bind work
|
|
93
|
+
|
|
94
|
+
# Now run Claude without specifying a profile
|
|
95
|
+
claude-profiles run
|
|
96
|
+
# => Using profile 'work' (from /path/to/project/.claude-profile)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This writes a `.claude-profile` TOML file in the directory:
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
profile = "work"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The `run` command walks up from the current directory looking for this file. If found, it uses that profile automatically. If not found and no name is given, it falls back to the interactive prompt.
|
|
106
|
+
|
|
107
|
+
To remove a binding:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
claude-profiles unbind
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Shell alias for quick access
|
|
114
|
+
|
|
115
|
+
`claude-profiles switch <name>` prints an alias you can add to your shell config:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## How Profiles Work
|
|
122
|
+
|
|
123
|
+
Claude Code uses the `CLAUDE_CONFIG_DIR` environment variable to locate its configuration directory. By default this is `~/.claude`. This tool creates and manages additional directories at `~/.claude-<name>`, each acting as a fully independent config root.
|
|
124
|
+
|
|
125
|
+
The default profile (`~/.claude`) is recognized and listed but cannot be created or deleted through this tool.
|
|
126
|
+
|
|
127
|
+
## Local Development
|
|
128
|
+
|
|
129
|
+
Requires [Bun](https://bun.sh).
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
git clone https://github.com/aeriondyseti/claude-profiles.git
|
|
133
|
+
cd claude-profiles
|
|
134
|
+
bun install
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Run locally:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
# Interactive TUI
|
|
141
|
+
bun src/cli.ts
|
|
142
|
+
|
|
143
|
+
# Specific command
|
|
144
|
+
bun src/cli.ts list
|
|
145
|
+
bun src/cli.ts create my-profile
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Typecheck:
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
bunx tsc --noEmit
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aeriondyseti/claude-profiles",
|
|
3
|
+
"version": "0.1.0-dev.9",
|
|
4
|
+
"description": "TUI and CLI for managing Claude Code profiles",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"claude-profiles": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "bun test",
|
|
17
|
+
"prepublishOnly": "bunx tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"cli",
|
|
22
|
+
"profiles",
|
|
23
|
+
"tui"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/aeriondyseti/claude-profiles"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@clack/prompts": "^0.10.0",
|
|
32
|
+
"smol-toml": "^1.6.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "^1.3.11",
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"typescript": "^5.7.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { main } from "./index.ts";
|
|
4
|
+
import { listProfiles } from "./commands/list.ts";
|
|
5
|
+
import { createProfileCommand } from "./commands/create.ts";
|
|
6
|
+
import { editProfileCommand } from "./commands/edit.ts";
|
|
7
|
+
import { cloneProfileCommand } from "./commands/clone.ts";
|
|
8
|
+
import { deleteProfileCommand } from "./commands/delete.ts";
|
|
9
|
+
import { switchProfileCommand } from "./commands/switch.ts";
|
|
10
|
+
import { runProfileCommand } from "./commands/run.ts";
|
|
11
|
+
import { bindProfileCommand } from "./commands/bind.ts";
|
|
12
|
+
import { unbindCommand } from "./commands/unbind.ts";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const command = args[0];
|
|
16
|
+
|
|
17
|
+
async function run(): Promise<void> {
|
|
18
|
+
switch (command) {
|
|
19
|
+
case "list":
|
|
20
|
+
case "ls":
|
|
21
|
+
await listProfiles();
|
|
22
|
+
break;
|
|
23
|
+
|
|
24
|
+
case "create":
|
|
25
|
+
case "new":
|
|
26
|
+
await createProfileCommand(args[1]);
|
|
27
|
+
break;
|
|
28
|
+
|
|
29
|
+
case "edit":
|
|
30
|
+
await editProfileCommand(args[1]);
|
|
31
|
+
break;
|
|
32
|
+
|
|
33
|
+
case "clone":
|
|
34
|
+
case "cp":
|
|
35
|
+
await cloneProfileCommand(args[1], args[2]);
|
|
36
|
+
break;
|
|
37
|
+
|
|
38
|
+
case "delete":
|
|
39
|
+
case "rm":
|
|
40
|
+
await deleteProfileCommand(args[1]);
|
|
41
|
+
break;
|
|
42
|
+
|
|
43
|
+
case "switch":
|
|
44
|
+
case "use":
|
|
45
|
+
await switchProfileCommand(args[1]);
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case "run":
|
|
49
|
+
case "exec": {
|
|
50
|
+
const dashDash = args.indexOf("--");
|
|
51
|
+
const profileName = args[1];
|
|
52
|
+
const extraArgs = dashDash >= 0 ? args.slice(dashDash + 1) : [];
|
|
53
|
+
await runProfileCommand(profileName, extraArgs);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case "bind":
|
|
58
|
+
await bindProfileCommand(args[1], args[2]);
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case "unbind":
|
|
62
|
+
await unbindCommand(args[1]);
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case "help":
|
|
66
|
+
case "--help":
|
|
67
|
+
case "-h":
|
|
68
|
+
printHelp();
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case undefined:
|
|
72
|
+
// No command — launch interactive TUI
|
|
73
|
+
await main();
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
default:
|
|
77
|
+
console.error(`Unknown command: ${command}`);
|
|
78
|
+
printHelp();
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printHelp(): void {
|
|
84
|
+
console.log(`
|
|
85
|
+
claude-profiles — Manage Claude Code profiles
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
claude-profiles Interactive TUI
|
|
89
|
+
claude-profiles list List all profiles
|
|
90
|
+
claude-profiles create [name] Create a new profile
|
|
91
|
+
claude-profiles edit [name] Edit profile settings.json
|
|
92
|
+
claude-profiles clone [source] [name] Clone a profile
|
|
93
|
+
claude-profiles delete [name] Delete a profile
|
|
94
|
+
claude-profiles switch [name] Show how to activate a profile
|
|
95
|
+
claude-profiles run [name] [-- args] Run Claude with a specific profile
|
|
96
|
+
claude-profiles bind [name] [path] Bind a profile to a directory (.claude-profile)
|
|
97
|
+
claude-profiles unbind [path] Remove .claude-profile from a directory
|
|
98
|
+
|
|
99
|
+
Aliases:
|
|
100
|
+
ls = list, new = create, cp = clone,
|
|
101
|
+
rm = delete, use = switch, exec = run
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
run().catch((err) => {
|
|
106
|
+
console.error(err);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { writeFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { stringify } from "smol-toml";
|
|
5
|
+
import { selectProfile } from "./shared.ts";
|
|
6
|
+
import { profileExists } from "../profiles.ts";
|
|
7
|
+
import { PROFILE_FILENAME } from "../detect.ts";
|
|
8
|
+
|
|
9
|
+
function isCancel(value: unknown): value is symbol {
|
|
10
|
+
return p.isCancel(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function bindProfileCommand(
|
|
14
|
+
nameArg?: string,
|
|
15
|
+
pathArg?: string,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
let profileName: string;
|
|
18
|
+
|
|
19
|
+
if (nameArg) {
|
|
20
|
+
if (!(await profileExists(nameArg))) {
|
|
21
|
+
p.log.error(`Profile "${nameArg}" does not exist.`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
profileName = nameArg;
|
|
25
|
+
} else {
|
|
26
|
+
const profile = await selectProfile("Bind which profile to this directory?");
|
|
27
|
+
if (!profile) return;
|
|
28
|
+
profileName = profile.name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dir = resolve(pathArg ?? process.cwd());
|
|
32
|
+
const filePath = join(dir, PROFILE_FILENAME);
|
|
33
|
+
|
|
34
|
+
// Check if file already exists
|
|
35
|
+
const exists = await stat(filePath)
|
|
36
|
+
.then(() => true)
|
|
37
|
+
.catch(() => false);
|
|
38
|
+
|
|
39
|
+
if (exists && !nameArg) {
|
|
40
|
+
const overwrite = await p.confirm({
|
|
41
|
+
message: `${filePath} already exists. Overwrite?`,
|
|
42
|
+
initialValue: false,
|
|
43
|
+
});
|
|
44
|
+
if (isCancel(overwrite) || !overwrite) {
|
|
45
|
+
p.cancel("Cancelled.");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const content = stringify({ profile: profileName });
|
|
51
|
+
await writeFile(filePath, content + "\n");
|
|
52
|
+
|
|
53
|
+
p.log.success(`Bound profile '${profileName}' to ${dir}`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { cloneProfile } from "../profiles.ts";
|
|
3
|
+
import { selectProfile } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
function isCancel(value: unknown): value is symbol {
|
|
6
|
+
return p.isCancel(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function cloneProfileCommand(
|
|
10
|
+
sourceArg?: string,
|
|
11
|
+
nameArg?: string,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const source = await selectProfile("Which profile to clone?", sourceArg);
|
|
14
|
+
if (!source) return;
|
|
15
|
+
|
|
16
|
+
let name = nameArg;
|
|
17
|
+
if (!name) {
|
|
18
|
+
const input = await p.text({
|
|
19
|
+
message: "New profile name:",
|
|
20
|
+
placeholder: `e.g. ${source.name}-copy`,
|
|
21
|
+
validate(value) {
|
|
22
|
+
if (!value.trim()) return "Name is required";
|
|
23
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(value))
|
|
24
|
+
return "Name must be alphanumeric (hyphens and underscores allowed)";
|
|
25
|
+
return undefined;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (isCancel(input)) {
|
|
29
|
+
p.cancel("Cancelled.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
name = input;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const dir = await cloneProfile(source.dir, name);
|
|
37
|
+
p.log.success(`Cloned "${source.name}" → "${name}" at ${dir}`);
|
|
38
|
+
p.log.info(
|
|
39
|
+
"Cloned: settings.json, CLAUDE.md, commands/, hooks/, agents/, skills/, output-styles/",
|
|
40
|
+
);
|
|
41
|
+
p.log.info("Excluded: sessions, history, auth state (.claude.json)");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
p.log.error(String(err));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { createProfile, getAllProfiles, readSettings, defaultSettings } from "../profiles.ts";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
function isCancel(value: unknown): value is symbol {
|
|
7
|
+
return p.isCancel(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function createProfileCommand(nameArg?: string): Promise<void> {
|
|
11
|
+
let name = nameArg;
|
|
12
|
+
|
|
13
|
+
if (!name) {
|
|
14
|
+
const input = await p.text({
|
|
15
|
+
message: "Profile name:",
|
|
16
|
+
placeholder: "e.g. work, personal, testing",
|
|
17
|
+
validate(value) {
|
|
18
|
+
if (!value.trim()) return "Name is required";
|
|
19
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(value))
|
|
20
|
+
return "Name must be alphanumeric (hyphens and underscores allowed)";
|
|
21
|
+
return undefined;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (isCancel(input)) {
|
|
25
|
+
p.cancel("Cancelled.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
name = input;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const profiles = await getAllProfiles();
|
|
32
|
+
|
|
33
|
+
let copyFrom: string | null = null;
|
|
34
|
+
if (profiles.length > 0) {
|
|
35
|
+
const copy = await p.select({
|
|
36
|
+
message: "Copy settings from an existing profile?",
|
|
37
|
+
options: [
|
|
38
|
+
{ value: "__none__", label: "No, start fresh" },
|
|
39
|
+
...profiles.map((prof) => ({
|
|
40
|
+
value: prof.dir,
|
|
41
|
+
label: prof.name,
|
|
42
|
+
})),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
if (isCancel(copy)) {
|
|
46
|
+
p.cancel("Cancelled.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (copy !== "__none__") {
|
|
50
|
+
copyFrom = copy as string;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dir = await createProfile(name);
|
|
55
|
+
|
|
56
|
+
if (copyFrom) {
|
|
57
|
+
const settings = await readSettings(copyFrom);
|
|
58
|
+
if (settings) {
|
|
59
|
+
const defaults = defaultSettings(name);
|
|
60
|
+
const merged = {
|
|
61
|
+
...settings,
|
|
62
|
+
hooks: {
|
|
63
|
+
...((settings.hooks as Record<string, unknown>) ?? {}),
|
|
64
|
+
...((defaults.hooks as Record<string, unknown>) ?? {}),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
await writeFile(
|
|
68
|
+
join(dir, "settings.json"),
|
|
69
|
+
JSON.stringify(merged, null, 2) + "\n",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
p.log.success(
|
|
75
|
+
`Created profile "${name}" at ${dir}`,
|
|
76
|
+
);
|
|
77
|
+
p.log.info(
|
|
78
|
+
`Run with: CLAUDE_CONFIG_DIR=${dir} claude`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { deleteProfile } from "../profiles.ts";
|
|
3
|
+
import { isActiveProfile, DEFAULT_PROFILE_NAME } from "../utils.ts";
|
|
4
|
+
import { selectProfile } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
function isCancel(value: unknown): value is symbol {
|
|
7
|
+
return p.isCancel(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function deleteProfileCommand(nameArg?: string): Promise<void> {
|
|
11
|
+
const profile = await selectProfile("Which profile to delete?", nameArg);
|
|
12
|
+
if (!profile) return;
|
|
13
|
+
|
|
14
|
+
if (profile.name === DEFAULT_PROFILE_NAME) {
|
|
15
|
+
p.log.error(`Cannot delete the default profile.`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (isActiveProfile(profile.dir)) {
|
|
20
|
+
p.log.error(
|
|
21
|
+
`Cannot delete "${profile.name}" — it is the currently active profile.`,
|
|
22
|
+
);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const confirmed = await p.confirm({
|
|
27
|
+
message: `Delete profile "${profile.name}"? This will remove ${profile.dir} and all its contents.`,
|
|
28
|
+
initialValue: false,
|
|
29
|
+
});
|
|
30
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
31
|
+
p.cancel("Cancelled.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await deleteProfile(profile.dir);
|
|
36
|
+
p.log.success(`Deleted profile "${profile.name}".`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { selectProfile } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
export async function editProfileCommand(nameArg?: string): Promise<void> {
|
|
7
|
+
const profile = await selectProfile("Which profile to edit?", nameArg);
|
|
8
|
+
if (!profile) return;
|
|
9
|
+
|
|
10
|
+
const settingsPath = join(profile.dir, "settings.json");
|
|
11
|
+
const editor = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
12
|
+
|
|
13
|
+
p.log.info(`Opening ${settingsPath} in ${editor}...`);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
execSync(`${editor} "${settingsPath}"`, { stdio: "inherit" });
|
|
17
|
+
p.log.success("Settings updated.");
|
|
18
|
+
} catch {
|
|
19
|
+
p.log.error("Editor exited with an error.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { getAllProfiles } from "../profiles.ts";
|
|
4
|
+
import { isActiveProfile } from "../utils.ts";
|
|
5
|
+
|
|
6
|
+
export async function listProfiles(): Promise<void> {
|
|
7
|
+
const profiles = await getAllProfiles();
|
|
8
|
+
|
|
9
|
+
if (profiles.length === 0) {
|
|
10
|
+
p.log.warn("No profiles found. Create one with `claude-profiles create`.");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
p.log.info(pc.bold("Claude Code Profiles"));
|
|
15
|
+
|
|
16
|
+
for (const profile of profiles) {
|
|
17
|
+
const active = isActiveProfile(profile.dir);
|
|
18
|
+
const marker = active ? pc.green("●") : pc.dim("○");
|
|
19
|
+
const name = active ? pc.green(pc.bold(profile.name)) : profile.name;
|
|
20
|
+
const details: string[] = [];
|
|
21
|
+
if (profile.email) details.push(profile.email);
|
|
22
|
+
// Only show org name if it's not just the email repeated
|
|
23
|
+
if (profile.orgName && !profile.orgName.includes(profile.email ?? ""))
|
|
24
|
+
details.push(profile.orgName);
|
|
25
|
+
const detailStr = details.length > 0 ? pc.dim(` (${details.join(" — ")})`) : "";
|
|
26
|
+
|
|
27
|
+
p.log.message(`${marker} ${name}${detailStr}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { selectProfile } from "./shared.ts";
|
|
4
|
+
import { detectProfile } from "../detect.ts";
|
|
5
|
+
|
|
6
|
+
export async function runProfileCommand(
|
|
7
|
+
nameArg?: string,
|
|
8
|
+
extraArgs: string[] = [],
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
let effectiveName = nameArg;
|
|
11
|
+
|
|
12
|
+
if (!effectiveName) {
|
|
13
|
+
const detected = await detectProfile();
|
|
14
|
+
if (detected) {
|
|
15
|
+
p.log.info(
|
|
16
|
+
`Using profile '${detected.name}' (from ${detected.filePath})`,
|
|
17
|
+
);
|
|
18
|
+
effectiveName = detected.name;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const profile = await selectProfile("Run Claude with which profile?", effectiveName);
|
|
23
|
+
if (!profile) return;
|
|
24
|
+
|
|
25
|
+
const claudeCmd = "claude";
|
|
26
|
+
p.log.info(
|
|
27
|
+
`Running: CLAUDE_CONFIG_DIR=${profile.dir} ${claudeCmd} ${extraArgs.join(" ")}`,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
execFileSync(claudeCmd, extraArgs, {
|
|
32
|
+
stdio: "inherit",
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
CLAUDE_CONFIG_DIR: profile.dir,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
} catch (err: unknown) {
|
|
39
|
+
const code = (err as { status?: number }).status;
|
|
40
|
+
if (code !== null && code !== undefined) {
|
|
41
|
+
process.exit(code);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { getAllProfiles, type ProfileInfo } from "../profiles.ts";
|
|
3
|
+
import { formatProfileOption } from "../utils.ts";
|
|
4
|
+
|
|
5
|
+
function isCancel(value: unknown): value is symbol {
|
|
6
|
+
return p.isCancel(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function selectProfile(
|
|
10
|
+
message: string,
|
|
11
|
+
nameArg?: string,
|
|
12
|
+
): Promise<ProfileInfo | null> {
|
|
13
|
+
const profiles = await getAllProfiles();
|
|
14
|
+
|
|
15
|
+
if (profiles.length === 0) {
|
|
16
|
+
p.log.warn("No profiles found. Create one first.");
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (nameArg) {
|
|
21
|
+
const match = profiles.find((prof) => prof.name === nameArg);
|
|
22
|
+
if (!match) {
|
|
23
|
+
p.log.error(
|
|
24
|
+
`Profile "${nameArg}" not found. Available: ${profiles.map((p) => p.name).join(", ")}`,
|
|
25
|
+
);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return match;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const selected = await p.select({
|
|
32
|
+
message,
|
|
33
|
+
options: profiles.map((prof) => ({
|
|
34
|
+
value: prof.dir,
|
|
35
|
+
label: formatProfileOption(prof.dir, prof.email),
|
|
36
|
+
})),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (isCancel(selected)) {
|
|
40
|
+
p.cancel("Cancelled.");
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return profiles.find((prof) => prof.dir === selected) ?? null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { selectProfile } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
export async function switchProfileCommand(nameArg?: string): Promise<void> {
|
|
6
|
+
const profile = await selectProfile("Switch to which profile?", nameArg);
|
|
7
|
+
if (!profile) return;
|
|
8
|
+
|
|
9
|
+
p.log.info("To activate this profile, run:");
|
|
10
|
+
p.log.message(
|
|
11
|
+
pc.cyan(`export CLAUDE_CONFIG_DIR=${profile.dir}`),
|
|
12
|
+
);
|
|
13
|
+
p.log.info("Or add an alias to your shell config:");
|
|
14
|
+
p.log.message(
|
|
15
|
+
pc.cyan(
|
|
16
|
+
`alias claude-${profile.name}='CLAUDE_CONFIG_DIR=${profile.dir} claude'`,
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
}
|