@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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { rm, stat } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { PROFILE_FILENAME, parseProfileFile } from "../detect.ts";
|
|
5
|
+
|
|
6
|
+
export async function unbindCommand(pathArg?: string): Promise<void> {
|
|
7
|
+
const dir = resolve(pathArg ?? process.cwd());
|
|
8
|
+
const filePath = join(dir, PROFILE_FILENAME);
|
|
9
|
+
|
|
10
|
+
const exists = await stat(filePath)
|
|
11
|
+
.then(() => true)
|
|
12
|
+
.catch(() => false);
|
|
13
|
+
|
|
14
|
+
if (!exists) {
|
|
15
|
+
p.log.warn(`No ${PROFILE_FILENAME} found in ${dir}`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const profileName = await parseProfileFile(filePath);
|
|
20
|
+
await rm(filePath);
|
|
21
|
+
|
|
22
|
+
if (profileName) {
|
|
23
|
+
p.log.success(`Removed profile binding '${profileName}' from ${dir}`);
|
|
24
|
+
} else {
|
|
25
|
+
p.log.success(`Removed ${PROFILE_FILENAME} from ${dir}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { parseProfileFile, detectProfile, PROFILE_FILENAME } from "./detect.ts";
|
|
6
|
+
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tempDir = await mkdtemp(join(tmpdir(), "claude-profiles-test-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("parseProfileFile", () => {
|
|
18
|
+
test("parses valid TOML with profile key", async () => {
|
|
19
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
20
|
+
await writeFile(filePath, 'profile = "work"\n');
|
|
21
|
+
expect(await parseProfileFile(filePath)).toBe("work");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("trims whitespace from profile name", async () => {
|
|
25
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
26
|
+
await writeFile(filePath, 'profile = " work "\n');
|
|
27
|
+
expect(await parseProfileFile(filePath)).toBe("work");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns null for missing file", async () => {
|
|
31
|
+
const filePath = join(tempDir, "nonexistent");
|
|
32
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns null for empty file", async () => {
|
|
36
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
37
|
+
await writeFile(filePath, "");
|
|
38
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns null when profile key is missing", async () => {
|
|
42
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
43
|
+
await writeFile(filePath, 'other = "value"\n');
|
|
44
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns null when profile key is not a string", async () => {
|
|
48
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
49
|
+
await writeFile(filePath, "profile = 42\n");
|
|
50
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns null when profile is empty string", async () => {
|
|
54
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
55
|
+
await writeFile(filePath, 'profile = ""\n');
|
|
56
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns null when profile is only whitespace", async () => {
|
|
60
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
61
|
+
await writeFile(filePath, 'profile = " "\n');
|
|
62
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns null for invalid TOML", async () => {
|
|
66
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
67
|
+
await writeFile(filePath, "not valid toml {{{}}}");
|
|
68
|
+
expect(await parseProfileFile(filePath)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("handles TOML with extra keys", async () => {
|
|
72
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
73
|
+
await writeFile(filePath, 'profile = "work"\nextra = "data"\n');
|
|
74
|
+
expect(await parseProfileFile(filePath)).toBe("work");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("detectProfile", () => {
|
|
79
|
+
test("finds .claude-profile in current directory", async () => {
|
|
80
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
81
|
+
await writeFile(filePath, 'profile = "work"\n');
|
|
82
|
+
|
|
83
|
+
const originalCwd = process.cwd();
|
|
84
|
+
process.chdir(tempDir);
|
|
85
|
+
try {
|
|
86
|
+
const result = await detectProfile();
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.name).toBe("work");
|
|
89
|
+
expect(result!.filePath).toBe(filePath);
|
|
90
|
+
} finally {
|
|
91
|
+
process.chdir(originalCwd);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("finds .claude-profile in ancestor directory", async () => {
|
|
96
|
+
const filePath = join(tempDir, PROFILE_FILENAME);
|
|
97
|
+
await writeFile(filePath, 'profile = "ancestor"\n');
|
|
98
|
+
|
|
99
|
+
const nested = join(tempDir, "a", "b", "c");
|
|
100
|
+
await mkdir(nested, { recursive: true });
|
|
101
|
+
|
|
102
|
+
const originalCwd = process.cwd();
|
|
103
|
+
process.chdir(nested);
|
|
104
|
+
try {
|
|
105
|
+
const result = await detectProfile();
|
|
106
|
+
expect(result).not.toBeNull();
|
|
107
|
+
expect(result!.name).toBe("ancestor");
|
|
108
|
+
expect(result!.filePath).toBe(filePath);
|
|
109
|
+
} finally {
|
|
110
|
+
process.chdir(originalCwd);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns closest .claude-profile when multiple exist", async () => {
|
|
115
|
+
// Parent has one profile
|
|
116
|
+
await writeFile(join(tempDir, PROFILE_FILENAME), 'profile = "parent"\n');
|
|
117
|
+
|
|
118
|
+
// Child has a different profile
|
|
119
|
+
const child = join(tempDir, "child");
|
|
120
|
+
await mkdir(child);
|
|
121
|
+
await writeFile(join(child, PROFILE_FILENAME), 'profile = "child"\n');
|
|
122
|
+
|
|
123
|
+
const originalCwd = process.cwd();
|
|
124
|
+
process.chdir(child);
|
|
125
|
+
try {
|
|
126
|
+
const result = await detectProfile();
|
|
127
|
+
expect(result).not.toBeNull();
|
|
128
|
+
expect(result!.name).toBe("child");
|
|
129
|
+
} finally {
|
|
130
|
+
process.chdir(originalCwd);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("returns null when no .claude-profile exists", async () => {
|
|
135
|
+
const nested = join(tempDir, "empty");
|
|
136
|
+
await mkdir(nested);
|
|
137
|
+
|
|
138
|
+
const originalCwd = process.cwd();
|
|
139
|
+
process.chdir(nested);
|
|
140
|
+
try {
|
|
141
|
+
// This will walk up to / and not find anything
|
|
142
|
+
// (unless the test runner's machine has one, which is unlikely in temp)
|
|
143
|
+
const result = await detectProfile();
|
|
144
|
+
// We can't guarantee null here since it walks to /, but we can test
|
|
145
|
+
// that it doesn't find one in our temp tree by checking the path
|
|
146
|
+
if (result) {
|
|
147
|
+
// If found, it should NOT be in our tempDir (since we didn't create one in "empty")
|
|
148
|
+
expect(result.filePath.startsWith(tempDir + "/empty")).toBe(false);
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
process.chdir(originalCwd);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("skips invalid .claude-profile files during walk", async () => {
|
|
156
|
+
// Child has invalid file
|
|
157
|
+
const child = join(tempDir, "child");
|
|
158
|
+
await mkdir(child);
|
|
159
|
+
await writeFile(join(child, PROFILE_FILENAME), "not valid toml {{{");
|
|
160
|
+
|
|
161
|
+
// Parent has valid file
|
|
162
|
+
await writeFile(join(tempDir, PROFILE_FILENAME), 'profile = "parent"\n');
|
|
163
|
+
|
|
164
|
+
const originalCwd = process.cwd();
|
|
165
|
+
process.chdir(child);
|
|
166
|
+
try {
|
|
167
|
+
const result = await detectProfile();
|
|
168
|
+
expect(result).not.toBeNull();
|
|
169
|
+
expect(result!.name).toBe("parent");
|
|
170
|
+
} finally {
|
|
171
|
+
process.chdir(originalCwd);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, dirname, parse as parsePath } from "node:path";
|
|
3
|
+
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
|
|
5
|
+
export const PROFILE_FILENAME = ".claude-profile";
|
|
6
|
+
|
|
7
|
+
export interface DetectedProfile {
|
|
8
|
+
name: string;
|
|
9
|
+
filePath: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function parseProfileFile(
|
|
13
|
+
filePath: string,
|
|
14
|
+
): Promise<string | null> {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(filePath, "utf-8");
|
|
17
|
+
const data = parseToml(raw);
|
|
18
|
+
const profile = data.profile;
|
|
19
|
+
if (typeof profile === "string" && profile.trim()) {
|
|
20
|
+
return profile.trim();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function detectProfile(): Promise<DetectedProfile | null> {
|
|
29
|
+
let dir = process.cwd();
|
|
30
|
+
|
|
31
|
+
while (true) {
|
|
32
|
+
const filePath = join(dir, PROFILE_FILENAME);
|
|
33
|
+
const name = await parseProfileFile(filePath);
|
|
34
|
+
if (name) {
|
|
35
|
+
return { name, filePath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir) break; // reached filesystem root
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
import { bindProfileCommand } from "./commands/bind.ts";
|
|
11
|
+
import { unbindCommand } from "./commands/unbind.ts";
|
|
12
|
+
|
|
13
|
+
function isCancel(value: unknown): value is symbol {
|
|
14
|
+
return p.isCancel(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function main(): Promise<void> {
|
|
18
|
+
p.intro(pc.bgCyan(pc.black(" claude-profiles ")));
|
|
19
|
+
|
|
20
|
+
while (true) {
|
|
21
|
+
const action = await p.select({
|
|
22
|
+
message: "What would you like to do?",
|
|
23
|
+
options: [
|
|
24
|
+
{ value: "list", label: "List profiles" },
|
|
25
|
+
{ value: "create", label: "Create profile" },
|
|
26
|
+
{ value: "edit", label: "Edit profile" },
|
|
27
|
+
{ value: "clone", label: "Clone profile" },
|
|
28
|
+
{ value: "delete", label: "Delete profile" },
|
|
29
|
+
{ value: "switch", label: "Switch active profile" },
|
|
30
|
+
{ value: "run", label: "Run Claude with profile" },
|
|
31
|
+
{ value: "bind", label: "Bind profile to directory" },
|
|
32
|
+
{ value: "unbind", label: "Unbind profile from directory" },
|
|
33
|
+
{ value: "exit", label: "Exit" },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (isCancel(action) || action === "exit") {
|
|
38
|
+
p.outro("Goodbye!");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
switch (action) {
|
|
43
|
+
case "list":
|
|
44
|
+
await listProfiles();
|
|
45
|
+
break;
|
|
46
|
+
case "create":
|
|
47
|
+
await createProfileCommand();
|
|
48
|
+
break;
|
|
49
|
+
case "edit":
|
|
50
|
+
await editProfileCommand();
|
|
51
|
+
break;
|
|
52
|
+
case "clone":
|
|
53
|
+
await cloneProfileCommand();
|
|
54
|
+
break;
|
|
55
|
+
case "delete":
|
|
56
|
+
await deleteProfileCommand();
|
|
57
|
+
break;
|
|
58
|
+
case "switch":
|
|
59
|
+
await switchProfileCommand();
|
|
60
|
+
break;
|
|
61
|
+
case "run":
|
|
62
|
+
await runProfileCommand();
|
|
63
|
+
break;
|
|
64
|
+
case "bind":
|
|
65
|
+
await bindProfileCommand();
|
|
66
|
+
break;
|
|
67
|
+
case "unbind":
|
|
68
|
+
await unbindCommand();
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { defaultSettings } from "./profiles.ts";
|
|
3
|
+
|
|
4
|
+
describe("defaultSettings", () => {
|
|
5
|
+
test("includes env with all three default vars", () => {
|
|
6
|
+
const settings = defaultSettings("test-profile");
|
|
7
|
+
const env = settings.env as Record<string, string>;
|
|
8
|
+
expect(env).toBeDefined();
|
|
9
|
+
expect(env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe("1");
|
|
10
|
+
expect(env.ENABLE_CLAUDEAI_MCP_SERVERS).toBe("false");
|
|
11
|
+
expect(env.CLAUDE_CODE_DISABLE_AUTO_MEMORY).toBe("1");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("includes exactly three env vars", () => {
|
|
15
|
+
const settings = defaultSettings("test");
|
|
16
|
+
const env = settings.env as Record<string, string>;
|
|
17
|
+
expect(Object.keys(env)).toHaveLength(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("includes SessionStart hook with profile name", () => {
|
|
21
|
+
const settings = defaultSettings("my-work");
|
|
22
|
+
const hooks = settings.hooks as Record<string, unknown[]>;
|
|
23
|
+
expect(hooks).toBeDefined();
|
|
24
|
+
expect(hooks.SessionStart).toBeArray();
|
|
25
|
+
|
|
26
|
+
const hookEntry = hooks.SessionStart[0] as Record<string, unknown>;
|
|
27
|
+
const hookList = hookEntry.hooks as Array<Record<string, string>>;
|
|
28
|
+
expect(hookList[0].command).toContain("my-work");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("embeds profile name in hook command", () => {
|
|
32
|
+
const settings = defaultSettings("special");
|
|
33
|
+
const hooks = settings.hooks as Record<string, unknown[]>;
|
|
34
|
+
const hookEntry = hooks.SessionStart[0] as Record<string, unknown>;
|
|
35
|
+
const hookList = hookEntry.hooks as Array<Record<string, string>>;
|
|
36
|
+
expect(hookList[0].command).toContain("'special'");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns a new object each time", () => {
|
|
40
|
+
const a = defaultSettings("test");
|
|
41
|
+
const b = defaultSettings("test");
|
|
42
|
+
expect(a).not.toBe(b);
|
|
43
|
+
expect(a).toEqual(b);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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, DEFAULT_PROFILE_NAME, DEFAULT_PROFILE_DIR, HOME } 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
|
+
function claudeJsonPath(dir: string): string {
|
|
28
|
+
// The default profile (~/.claude) stores .claude.json in ~ instead of inside the config dir
|
|
29
|
+
if (dir === DEFAULT_PROFILE_DIR) {
|
|
30
|
+
return join(HOME, ".claude.json");
|
|
31
|
+
}
|
|
32
|
+
return join(dir, ".claude.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readClaudeJson(
|
|
36
|
+
dir: string,
|
|
37
|
+
): Promise<Record<string, unknown> | null> {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await readFile(claudeJsonPath(dir), "utf-8");
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readSettings(
|
|
47
|
+
dir: string,
|
|
48
|
+
): Promise<Record<string, unknown> | null> {
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(join(dir, "settings.json"), "utf-8");
|
|
51
|
+
return JSON.parse(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getProfileInfo(dir: string): Promise<ProfileInfo> {
|
|
58
|
+
const claudeJson = await readClaudeJson(dir);
|
|
59
|
+
const settings = await readSettings(dir);
|
|
60
|
+
const oauth = claudeJson?.oauthAccount as
|
|
61
|
+
| Record<string, unknown>
|
|
62
|
+
| undefined;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: profileNameFromDir(dir),
|
|
66
|
+
dir,
|
|
67
|
+
email: (oauth?.emailAddress as string) ?? null,
|
|
68
|
+
orgName: (oauth?.organizationName as string) ?? null,
|
|
69
|
+
hasSettings: settings !== null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getAllProfiles(): Promise<ProfileInfo[]> {
|
|
74
|
+
const dirs = await discoverProfiles();
|
|
75
|
+
return Promise.all(dirs.map(getProfileInfo));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function defaultSettings(profileName: string): Record<string, unknown> {
|
|
79
|
+
return {
|
|
80
|
+
env: {
|
|
81
|
+
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1",
|
|
82
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
|
|
83
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
|
|
84
|
+
},
|
|
85
|
+
hooks: {
|
|
86
|
+
SessionStart: [
|
|
87
|
+
{
|
|
88
|
+
matcher: "",
|
|
89
|
+
hooks: [
|
|
90
|
+
{
|
|
91
|
+
type: "command",
|
|
92
|
+
command: `echo "claude-profiles: active profile is '${profileName}' ($CLAUDE_CONFIG_DIR)"`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function createProfile(name: string): Promise<string> {
|
|
102
|
+
if (name === DEFAULT_PROFILE_NAME) {
|
|
103
|
+
throw new Error(`Cannot create a profile named "${DEFAULT_PROFILE_NAME}" — it refers to the built-in ~/.claude directory`);
|
|
104
|
+
}
|
|
105
|
+
const dir = getProfileDir(name);
|
|
106
|
+
if (await exists(dir)) {
|
|
107
|
+
throw new Error(`Profile "${name}" already exists at ${dir}`);
|
|
108
|
+
}
|
|
109
|
+
await mkdir(dir, { recursive: true });
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(dir, "settings.json"),
|
|
112
|
+
JSON.stringify(defaultSettings(name), null, 2) + "\n",
|
|
113
|
+
);
|
|
114
|
+
return dir;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Directories and files to copy when cloning (config-only, no session data)
|
|
118
|
+
const CLONE_ITEMS = [
|
|
119
|
+
"settings.json",
|
|
120
|
+
"CLAUDE.md",
|
|
121
|
+
"commands",
|
|
122
|
+
"hooks",
|
|
123
|
+
"agents",
|
|
124
|
+
"skills",
|
|
125
|
+
"output-styles",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
async function copyDir(src: string, dest: string): Promise<void> {
|
|
129
|
+
await mkdir(dest, { recursive: true });
|
|
130
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
const srcPath = join(src, entry.name);
|
|
133
|
+
const destPath = join(dest, entry.name);
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
await copyDir(srcPath, destPath);
|
|
136
|
+
} else {
|
|
137
|
+
await copyFile(srcPath, destPath);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function cloneProfile(
|
|
143
|
+
sourceDir: string,
|
|
144
|
+
newName: string,
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const destDir = getProfileDir(newName);
|
|
147
|
+
if (await exists(destDir)) {
|
|
148
|
+
throw new Error(`Profile "${newName}" already exists at ${destDir}`);
|
|
149
|
+
}
|
|
150
|
+
await mkdir(destDir, { recursive: true });
|
|
151
|
+
|
|
152
|
+
for (const item of CLONE_ITEMS) {
|
|
153
|
+
const srcPath = join(sourceDir, item);
|
|
154
|
+
const destPath = join(destDir, item);
|
|
155
|
+
const s = await stat(srcPath).catch(() => null);
|
|
156
|
+
if (!s) continue;
|
|
157
|
+
|
|
158
|
+
if (s.isDirectory()) {
|
|
159
|
+
await copyDir(srcPath, destPath);
|
|
160
|
+
} else {
|
|
161
|
+
await copyFile(srcPath, destPath);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return destDir;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function deleteProfile(dir: string): Promise<void> {
|
|
169
|
+
await rm(dir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function profileExists(name: string): Promise<boolean> {
|
|
173
|
+
return exists(getProfileDir(name));
|
|
174
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
getProfileDir,
|
|
4
|
+
profileNameFromDir,
|
|
5
|
+
getActiveProfileDir,
|
|
6
|
+
isActiveProfile,
|
|
7
|
+
HOME,
|
|
8
|
+
CLAUDE_DIR_PREFIX,
|
|
9
|
+
DEFAULT_PROFILE_NAME,
|
|
10
|
+
DEFAULT_PROFILE_DIR,
|
|
11
|
+
} from "./utils.ts";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
describe("getProfileDir", () => {
|
|
15
|
+
test("returns ~/.claude for default profile", () => {
|
|
16
|
+
expect(getProfileDir("default")).toBe(join(HOME, ".claude"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns ~/.claude-<name> for named profiles", () => {
|
|
20
|
+
expect(getProfileDir("work")).toBe(join(HOME, ".claude-work"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("handles hyphens in profile names", () => {
|
|
24
|
+
expect(getProfileDir("my-project")).toBe(join(HOME, ".claude-my-project"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("handles underscores in profile names", () => {
|
|
28
|
+
expect(getProfileDir("my_project")).toBe(join(HOME, ".claude-my_project"));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("profileNameFromDir", () => {
|
|
33
|
+
test("returns 'default' for ~/.claude", () => {
|
|
34
|
+
expect(profileNameFromDir(DEFAULT_PROFILE_DIR)).toBe("default");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("returns 'default' for any path ending in .claude", () => {
|
|
38
|
+
expect(profileNameFromDir("/some/path/.claude")).toBe("default");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("extracts name from ~/.claude-<name> path", () => {
|
|
42
|
+
expect(profileNameFromDir(join(HOME, ".claude-work"))).toBe("work");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("extracts hyphenated names correctly", () => {
|
|
46
|
+
expect(profileNameFromDir(join(HOME, ".claude-my-project"))).toBe(
|
|
47
|
+
"my-project",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("getActiveProfileDir", () => {
|
|
53
|
+
const originalEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
if (originalEnv !== undefined) {
|
|
57
|
+
process.env.CLAUDE_CONFIG_DIR = originalEnv;
|
|
58
|
+
} else {
|
|
59
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns null when CLAUDE_CONFIG_DIR is not set", () => {
|
|
64
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
65
|
+
expect(getActiveProfileDir()).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns null when CLAUDE_CONFIG_DIR is empty", () => {
|
|
69
|
+
process.env.CLAUDE_CONFIG_DIR = "";
|
|
70
|
+
expect(getActiveProfileDir()).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns the value of CLAUDE_CONFIG_DIR", () => {
|
|
74
|
+
process.env.CLAUDE_CONFIG_DIR = "/home/user/.claude-work";
|
|
75
|
+
expect(getActiveProfileDir()).toBe("/home/user/.claude-work");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isActiveProfile", () => {
|
|
80
|
+
const originalEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
if (originalEnv !== undefined) {
|
|
84
|
+
process.env.CLAUDE_CONFIG_DIR = originalEnv;
|
|
85
|
+
} else {
|
|
86
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns false when no profile is active", () => {
|
|
91
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
92
|
+
expect(isActiveProfile(join(HOME, ".claude-work"))).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns true for matching profile dir", () => {
|
|
96
|
+
const dir = join(HOME, ".claude-work");
|
|
97
|
+
process.env.CLAUDE_CONFIG_DIR = dir;
|
|
98
|
+
expect(isActiveProfile(dir)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns false for non-matching profile dir", () => {
|
|
102
|
+
process.env.CLAUDE_CONFIG_DIR = join(HOME, ".claude-work");
|
|
103
|
+
expect(isActiveProfile(join(HOME, ".claude-personal"))).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("normalizes trailing slashes", () => {
|
|
107
|
+
const dir = join(HOME, ".claude-work");
|
|
108
|
+
process.env.CLAUDE_CONFIG_DIR = dir + "/";
|
|
109
|
+
expect(isActiveProfile(dir)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("normalizes tilde in env var", () => {
|
|
113
|
+
process.env.CLAUDE_CONFIG_DIR = "~/.claude-work";
|
|
114
|
+
expect(isActiveProfile(join(HOME, ".claude-work"))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("constants", () => {
|
|
119
|
+
test("HOME is set", () => {
|
|
120
|
+
expect(HOME).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("CLAUDE_DIR_PREFIX is .claude-", () => {
|
|
124
|
+
expect(CLAUDE_DIR_PREFIX).toBe(".claude-");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("DEFAULT_PROFILE_NAME is 'default'", () => {
|
|
128
|
+
expect(DEFAULT_PROFILE_NAME).toBe("default");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("DEFAULT_PROFILE_DIR is HOME/.claude", () => {
|
|
132
|
+
expect(DEFAULT_PROFILE_DIR).toBe(join(HOME, ".claude"));
|
|
133
|
+
});
|
|
134
|
+
});
|