@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 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
+ }