@aeriondyseti/claude-profiles 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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@aeriondyseti/claude-profiles",
3
+ "version": "0.1.0",
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
+ "prepublishOnly": "bunx tsc --noEmit"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "cli",
21
+ "profiles",
22
+ "tui"
23
+ ],
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@clack/prompts": "^0.10.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.0.0",
30
+ "typescript": "^5.7.0"
31
+ }
32
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,96 @@
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
+
12
+ const args = process.argv.slice(2);
13
+ const command = args[0];
14
+
15
+ async function run(): Promise<void> {
16
+ switch (command) {
17
+ case "list":
18
+ case "ls":
19
+ await listProfiles();
20
+ break;
21
+
22
+ case "create":
23
+ case "new":
24
+ await createProfileCommand(args[1]);
25
+ break;
26
+
27
+ case "edit":
28
+ await editProfileCommand(args[1]);
29
+ break;
30
+
31
+ case "clone":
32
+ case "cp":
33
+ await cloneProfileCommand(args[1], args[2]);
34
+ break;
35
+
36
+ case "delete":
37
+ case "rm":
38
+ await deleteProfileCommand(args[1]);
39
+ break;
40
+
41
+ case "switch":
42
+ case "use":
43
+ await switchProfileCommand(args[1]);
44
+ break;
45
+
46
+ case "run":
47
+ case "exec": {
48
+ const dashDash = args.indexOf("--");
49
+ const profileName = args[1];
50
+ const extraArgs = dashDash >= 0 ? args.slice(dashDash + 1) : [];
51
+ await runProfileCommand(profileName, extraArgs);
52
+ break;
53
+ }
54
+
55
+ case "help":
56
+ case "--help":
57
+ case "-h":
58
+ printHelp();
59
+ break;
60
+
61
+ case undefined:
62
+ // No command — launch interactive TUI
63
+ await main();
64
+ break;
65
+
66
+ default:
67
+ console.error(`Unknown command: ${command}`);
68
+ printHelp();
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ function printHelp(): void {
74
+ console.log(`
75
+ claude-profiles — Manage Claude Code profiles
76
+
77
+ Usage:
78
+ claude-profiles Interactive TUI
79
+ claude-profiles list List all profiles
80
+ claude-profiles create [name] Create a new profile
81
+ claude-profiles edit [name] Edit profile settings.json
82
+ claude-profiles clone [source] [name] Clone a profile
83
+ claude-profiles delete [name] Delete a profile
84
+ claude-profiles switch [name] Show how to activate a profile
85
+ claude-profiles run [name] [-- args] Run Claude with a specific profile
86
+
87
+ Aliases:
88
+ ls = list, new = create, cp = clone,
89
+ rm = delete, use = switch, exec = run
90
+ `);
91
+ }
92
+
93
+ run().catch((err) => {
94
+ console.error(err);
95
+ process.exit(1);
96
+ });
@@ -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,72 @@
1
+ import * as p from "@clack/prompts";
2
+ import { createProfile, getAllProfiles, readSettings } 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
+ await writeFile(
60
+ join(dir, "settings.json"),
61
+ JSON.stringify(settings, null, 2) + "\n",
62
+ );
63
+ }
64
+ }
65
+
66
+ p.log.success(
67
+ `Created profile "${name}" at ${dir}`,
68
+ );
69
+ p.log.info(
70
+ `Run with: CLAUDE_CONFIG_DIR=${dir} claude`,
71
+ );
72
+ }
@@ -0,0 +1,32 @@
1
+ import * as p from "@clack/prompts";
2
+ import { deleteProfile } from "../profiles.ts";
3
+ import { isActiveProfile } 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 (isActiveProfile(profile.dir)) {
15
+ p.log.error(
16
+ `Cannot delete "${profile.name}" — it is the currently active profile.`,
17
+ );
18
+ return;
19
+ }
20
+
21
+ const confirmed = await p.confirm({
22
+ message: `Delete profile "${profile.name}"? This will remove ${profile.dir} and all its contents.`,
23
+ initialValue: false,
24
+ });
25
+ if (isCancel(confirmed) || !confirmed) {
26
+ p.cancel("Cancelled.");
27
+ return;
28
+ }
29
+
30
+ await deleteProfile(profile.dir);
31
+ p.log.success(`Deleted profile "${profile.name}".`);
32
+ }
@@ -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,31 @@
1
+ import * as p from "@clack/prompts";
2
+ import { execFileSync } from "node:child_process";
3
+ import { selectProfile } from "./shared.ts";
4
+
5
+ export async function runProfileCommand(
6
+ nameArg?: string,
7
+ extraArgs: string[] = [],
8
+ ): Promise<void> {
9
+ const profile = await selectProfile("Run Claude with which profile?", nameArg);
10
+ if (!profile) return;
11
+
12
+ const claudeCmd = "claude";
13
+ p.log.info(
14
+ `Running: CLAUDE_CONFIG_DIR=${profile.dir} ${claudeCmd} ${extraArgs.join(" ")}`,
15
+ );
16
+
17
+ try {
18
+ execFileSync(claudeCmd, extraArgs, {
19
+ stdio: "inherit",
20
+ env: {
21
+ ...process.env,
22
+ CLAUDE_CONFIG_DIR: profile.dir,
23
+ },
24
+ });
25
+ } catch (err: unknown) {
26
+ const code = (err as { status?: number }).status;
27
+ if (code !== null && code !== undefined) {
28
+ process.exit(code);
29
+ }
30
+ }
31
+ }
@@ -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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { listProfiles } from "./commands/list.ts";
4
+ import { createProfileCommand } from "./commands/create.ts";
5
+ import { editProfileCommand } from "./commands/edit.ts";
6
+ import { cloneProfileCommand } from "./commands/clone.ts";
7
+ import { deleteProfileCommand } from "./commands/delete.ts";
8
+ import { switchProfileCommand } from "./commands/switch.ts";
9
+ import { runProfileCommand } from "./commands/run.ts";
10
+
11
+ function isCancel(value: unknown): value is symbol {
12
+ return p.isCancel(value);
13
+ }
14
+
15
+ export async function main(): Promise<void> {
16
+ p.intro(pc.bgCyan(pc.black(" claude-profiles ")));
17
+
18
+ while (true) {
19
+ const action = await p.select({
20
+ message: "What would you like to do?",
21
+ options: [
22
+ { value: "list", label: "List profiles" },
23
+ { value: "create", label: "Create profile" },
24
+ { value: "edit", label: "Edit profile" },
25
+ { value: "clone", label: "Clone profile" },
26
+ { value: "delete", label: "Delete profile" },
27
+ { value: "switch", label: "Switch active profile" },
28
+ { value: "run", label: "Run Claude with profile" },
29
+ { value: "exit", label: "Exit" },
30
+ ],
31
+ });
32
+
33
+ if (isCancel(action) || action === "exit") {
34
+ p.outro("Goodbye!");
35
+ return;
36
+ }
37
+
38
+ switch (action) {
39
+ case "list":
40
+ await listProfiles();
41
+ break;
42
+ case "create":
43
+ await createProfileCommand();
44
+ break;
45
+ case "edit":
46
+ await editProfileCommand();
47
+ break;
48
+ case "clone":
49
+ await cloneProfileCommand();
50
+ break;
51
+ case "delete":
52
+ await deleteProfileCommand();
53
+ break;
54
+ case "switch":
55
+ await switchProfileCommand();
56
+ break;
57
+ case "run":
58
+ await runProfileCommand();
59
+ break;
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,137 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ readFile,
4
+ writeFile,
5
+ mkdir,
6
+ rm,
7
+ readdir,
8
+ copyFile,
9
+ stat,
10
+ } from "node:fs/promises";
11
+ import { getProfileDir, discoverProfiles, profileNameFromDir } from "./utils.ts";
12
+
13
+ export interface ProfileInfo {
14
+ name: string;
15
+ dir: string;
16
+ email: string | null;
17
+ orgName: string | null;
18
+ hasSettings: boolean;
19
+ }
20
+
21
+ async function exists(path: string): Promise<boolean> {
22
+ return stat(path)
23
+ .then(() => true)
24
+ .catch(() => false);
25
+ }
26
+
27
+ export async function readClaudeJson(
28
+ dir: string,
29
+ ): Promise<Record<string, unknown> | null> {
30
+ try {
31
+ const raw = await readFile(join(dir, ".claude.json"), "utf-8");
32
+ return JSON.parse(raw);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export async function readSettings(
39
+ dir: string,
40
+ ): Promise<Record<string, unknown> | null> {
41
+ try {
42
+ const raw = await readFile(join(dir, "settings.json"), "utf-8");
43
+ return JSON.parse(raw);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function getProfileInfo(dir: string): Promise<ProfileInfo> {
50
+ const claudeJson = await readClaudeJson(dir);
51
+ const settings = await readSettings(dir);
52
+ const oauth = claudeJson?.oauthAccount as
53
+ | Record<string, unknown>
54
+ | undefined;
55
+
56
+ return {
57
+ name: profileNameFromDir(dir),
58
+ dir,
59
+ email: (oauth?.emailAddress as string) ?? null,
60
+ orgName: (oauth?.organizationName as string) ?? null,
61
+ hasSettings: settings !== null,
62
+ };
63
+ }
64
+
65
+ export async function getAllProfiles(): Promise<ProfileInfo[]> {
66
+ const dirs = await discoverProfiles();
67
+ return Promise.all(dirs.map(getProfileInfo));
68
+ }
69
+
70
+ export async function createProfile(name: string): Promise<string> {
71
+ const dir = getProfileDir(name);
72
+ if (await exists(dir)) {
73
+ throw new Error(`Profile "${name}" already exists at ${dir}`);
74
+ }
75
+ await mkdir(dir, { recursive: true });
76
+ await writeFile(join(dir, "settings.json"), "{}\n");
77
+ return dir;
78
+ }
79
+
80
+ // Directories and files to copy when cloning (config-only, no session data)
81
+ const CLONE_ITEMS = [
82
+ "settings.json",
83
+ "CLAUDE.md",
84
+ "commands",
85
+ "hooks",
86
+ "agents",
87
+ "skills",
88
+ "output-styles",
89
+ ];
90
+
91
+ async function copyDir(src: string, dest: string): Promise<void> {
92
+ await mkdir(dest, { recursive: true });
93
+ const entries = await readdir(src, { withFileTypes: true });
94
+ for (const entry of entries) {
95
+ const srcPath = join(src, entry.name);
96
+ const destPath = join(dest, entry.name);
97
+ if (entry.isDirectory()) {
98
+ await copyDir(srcPath, destPath);
99
+ } else {
100
+ await copyFile(srcPath, destPath);
101
+ }
102
+ }
103
+ }
104
+
105
+ export async function cloneProfile(
106
+ sourceDir: string,
107
+ newName: string,
108
+ ): Promise<string> {
109
+ const destDir = getProfileDir(newName);
110
+ if (await exists(destDir)) {
111
+ throw new Error(`Profile "${newName}" already exists at ${destDir}`);
112
+ }
113
+ await mkdir(destDir, { recursive: true });
114
+
115
+ for (const item of CLONE_ITEMS) {
116
+ const srcPath = join(sourceDir, item);
117
+ const destPath = join(destDir, item);
118
+ const s = await stat(srcPath).catch(() => null);
119
+ if (!s) continue;
120
+
121
+ if (s.isDirectory()) {
122
+ await copyDir(srcPath, destPath);
123
+ } else {
124
+ await copyFile(srcPath, destPath);
125
+ }
126
+ }
127
+
128
+ return destDir;
129
+ }
130
+
131
+ export async function deleteProfile(dir: string): Promise<void> {
132
+ await rm(dir, { recursive: true, force: true });
133
+ }
134
+
135
+ export async function profileExists(name: string): Promise<boolean> {
136
+ return exists(getProfileDir(name));
137
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { homedir } from "node:os";
2
+ import { join, basename } from "node:path";
3
+ import { readdir, stat } from "node:fs/promises";
4
+ import pc from "picocolors";
5
+
6
+ export const HOME = homedir();
7
+ export const CLAUDE_DIR_PREFIX = ".claude-";
8
+ export const EXCLUDED_DIRS = new Set(["worktrees"]);
9
+
10
+ export function getProfileDir(name: string): string {
11
+ return join(HOME, `${CLAUDE_DIR_PREFIX}${name}`);
12
+ }
13
+
14
+ export function profileNameFromDir(dir: string): string {
15
+ return basename(dir).replace(CLAUDE_DIR_PREFIX, "");
16
+ }
17
+
18
+ export function getActiveProfileDir(): string | null {
19
+ return process.env.CLAUDE_CONFIG_DIR || null;
20
+ }
21
+
22
+ export function isActiveProfile(dir: string): boolean {
23
+ const active = getActiveProfileDir();
24
+ if (!active) return false;
25
+ // Normalize ~ and trailing slashes
26
+ const normalize = (p: string) =>
27
+ p.replace(/^~/, HOME).replace(/\/+$/, "");
28
+ return normalize(active) === normalize(dir);
29
+ }
30
+
31
+ export async function discoverProfiles(): Promise<string[]> {
32
+ const entries = await readdir(HOME);
33
+ const profiles: string[] = [];
34
+
35
+ for (const entry of entries) {
36
+ if (!entry.startsWith(CLAUDE_DIR_PREFIX)) continue;
37
+ const name = entry.slice(CLAUDE_DIR_PREFIX.length);
38
+ if (EXCLUDED_DIRS.has(name)) continue;
39
+
40
+ const fullPath = join(HOME, entry);
41
+ const s = await stat(fullPath).catch(() => null);
42
+ if (s?.isDirectory()) {
43
+ profiles.push(fullPath);
44
+ }
45
+ }
46
+
47
+ return profiles.sort();
48
+ }
49
+
50
+ export function formatProfileName(dir: string): string {
51
+ const name = profileNameFromDir(dir);
52
+ const active = isActiveProfile(dir);
53
+ return active ? pc.green(`${name} (active)`) : name;
54
+ }
55
+
56
+ export function formatProfileOption(
57
+ dir: string,
58
+ email?: string | null,
59
+ ): string {
60
+ const name = profileNameFromDir(dir);
61
+ const active = isActiveProfile(dir);
62
+ const parts: string[] = [name];
63
+ if (email) parts.push(pc.dim(`(${email})`));
64
+ if (active) parts.push(pc.green("● active"));
65
+ return parts.join(" ");
66
+ }