claude-switch-profile 1.0.2 → 1.4.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/CHANGELOG.md +69 -0
- package/README.md +205 -55
- package/bin/csp.js +52 -3
- package/package.json +1 -1
- package/src/commands/create.js +76 -18
- package/src/commands/current.js +8 -3
- package/src/commands/deactivate.js +22 -0
- package/src/commands/delete.js +19 -7
- package/src/commands/diff.js +19 -9
- package/src/commands/export.js +30 -5
- package/src/commands/import.js +11 -2
- package/src/commands/init.js +10 -8
- package/src/commands/launch.js +216 -0
- package/src/commands/list.js +2 -1
- package/src/commands/save.js +11 -4
- package/src/commands/select.js +126 -0
- package/src/commands/status.js +48 -0
- package/src/commands/toggle.js +25 -0
- package/src/commands/uninstall.js +99 -0
- package/src/commands/use.js +67 -58
- package/src/constants.js +43 -16
- package/src/file-operations.js +80 -1
- package/src/item-manager.js +155 -0
- package/src/launch-effective-env-resolver.js +192 -0
- package/src/platform.js +24 -0
- package/src/profile-store.js +139 -7
- package/src/profile-validator.js +7 -9
- package/src/runtime-instance-manager.js +190 -0
- package/src/safety.js +72 -35
- package/src/symlink-manager.js +0 -69
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, cpSync, rmSync, mkdirSync, lstatSync, copyFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename } from 'node:path';
|
|
3
|
+
import { CLAUDE_DIR, MANAGED_ITEMS, SOURCE_FILE } from './constants.js';
|
|
4
|
+
|
|
5
|
+
const SKIP_PATTERNS = ['.venv', 'node_modules', '__pycache__', '.git'];
|
|
6
|
+
const skipHeavyDirs = (src) => !SKIP_PATTERNS.includes(basename(src));
|
|
7
|
+
|
|
8
|
+
// Rename-based move with EXDEV fallback (filtered copy + delete)
|
|
9
|
+
const moveItem = (src, dest) => {
|
|
10
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
11
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
12
|
+
try {
|
|
13
|
+
renameSync(src, dest);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err.code === 'EXDEV') {
|
|
16
|
+
cpSync(src, dest, { recursive: true, filter: skipHeavyDirs });
|
|
17
|
+
rmSync(src, { recursive: true, force: true });
|
|
18
|
+
} else throw err;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Read current managed items from ~/.claude — returns map of {item: claudeDir/item}
|
|
23
|
+
export const readCurrentItems = () => {
|
|
24
|
+
const itemMap = {};
|
|
25
|
+
for (const item of MANAGED_ITEMS) {
|
|
26
|
+
const itemPath = join(CLAUDE_DIR, item);
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(itemPath)) {
|
|
29
|
+
itemMap[item] = itemPath;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Item doesn't exist or isn't readable — skip
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return itemMap;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Remove all managed items (dirs/files) from ~/.claude
|
|
39
|
+
export const removeItems = () => {
|
|
40
|
+
for (const item of MANAGED_ITEMS) {
|
|
41
|
+
const itemPath = join(CLAUDE_DIR, item);
|
|
42
|
+
try {
|
|
43
|
+
if (existsSync(itemPath)) {
|
|
44
|
+
rmSync(itemPath, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Already gone — skip
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Copy managed items from ~/.claude into profileDir, write source.json manifest (non-destructive)
|
|
53
|
+
export const copyItems = (profileDir) => {
|
|
54
|
+
const sourceMap = {};
|
|
55
|
+
for (const item of MANAGED_ITEMS) {
|
|
56
|
+
const itemPath = join(CLAUDE_DIR, item);
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(itemPath)) continue;
|
|
59
|
+
|
|
60
|
+
const dest = join(profileDir, item);
|
|
61
|
+
const stat = lstatSync(itemPath);
|
|
62
|
+
|
|
63
|
+
if (stat.isDirectory()) {
|
|
64
|
+
rmSync(dest, { recursive: true, force: true });
|
|
65
|
+
cpSync(itemPath, dest, { recursive: true });
|
|
66
|
+
} else if (stat.isFile()) {
|
|
67
|
+
copyFileSync(itemPath, dest);
|
|
68
|
+
}
|
|
69
|
+
sourceMap[item] = dest;
|
|
70
|
+
} catch {
|
|
71
|
+
// Not readable — skip
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
|
|
75
|
+
return sourceMap;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Backward-compatible alias for save.js
|
|
79
|
+
export const saveItems = copyItems;
|
|
80
|
+
|
|
81
|
+
// Read source.json from profileDir and copy items into ~/.claude (non-destructive)
|
|
82
|
+
export const restoreItems = (profileDir) => {
|
|
83
|
+
const sourcePath = join(profileDir, SOURCE_FILE);
|
|
84
|
+
if (!existsSync(sourcePath)) return {};
|
|
85
|
+
|
|
86
|
+
const sourceMap = JSON.parse(readFileSync(sourcePath, 'utf-8'));
|
|
87
|
+
|
|
88
|
+
for (const [item, srcPath] of Object.entries(sourceMap)) {
|
|
89
|
+
if (!MANAGED_ITEMS.includes(item)) continue;
|
|
90
|
+
|
|
91
|
+
const dest = join(CLAUDE_DIR, item);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
95
|
+
} catch {
|
|
96
|
+
// fine
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Copy from profile (or external legacy path) into ~/.claude
|
|
100
|
+
if (existsSync(srcPath)) {
|
|
101
|
+
try {
|
|
102
|
+
const stat = lstatSync(srcPath);
|
|
103
|
+
if (stat.isDirectory()) {
|
|
104
|
+
cpSync(srcPath, dest, { recursive: true });
|
|
105
|
+
} else if (stat.isFile()) {
|
|
106
|
+
copyFileSync(srcPath, dest);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// skip unreadable
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sourceMap;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Move items from ~/.claude → profileDir (destructive — items leave ~/.claude)
|
|
118
|
+
export const moveItemsToProfile = (profileDir) => {
|
|
119
|
+
mkdirSync(profileDir, { recursive: true });
|
|
120
|
+
const sourceMap = {};
|
|
121
|
+
for (const item of MANAGED_ITEMS) {
|
|
122
|
+
const itemPath = join(CLAUDE_DIR, item);
|
|
123
|
+
if (!existsSync(itemPath)) continue;
|
|
124
|
+
try {
|
|
125
|
+
const dest = join(profileDir, item);
|
|
126
|
+
moveItem(itemPath, dest);
|
|
127
|
+
sourceMap[item] = dest;
|
|
128
|
+
} catch {
|
|
129
|
+
// skip
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
|
|
133
|
+
return sourceMap;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Move items from profileDir → ~/.claude (destructive — items leave profileDir)
|
|
137
|
+
export const moveItemsToClaude = (profileDir) => {
|
|
138
|
+
const sourcePath = join(profileDir, SOURCE_FILE);
|
|
139
|
+
if (!existsSync(sourcePath)) return {};
|
|
140
|
+
const sourceMap = JSON.parse(readFileSync(sourcePath, 'utf-8'));
|
|
141
|
+
|
|
142
|
+
for (const [item] of Object.entries(sourceMap)) {
|
|
143
|
+
if (!MANAGED_ITEMS.includes(item)) continue;
|
|
144
|
+
const src = join(profileDir, item);
|
|
145
|
+
const dest = join(CLAUDE_DIR, item);
|
|
146
|
+
if (!existsSync(src)) continue;
|
|
147
|
+
try {
|
|
148
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
149
|
+
moveItem(src, dest);
|
|
150
|
+
} catch {
|
|
151
|
+
// skip
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return sourceMap;
|
|
155
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { LAUNCH_ANTHROPIC_ENV_KEYS, LAUNCH_CONFIG_ENV } from './constants.js';
|
|
2
|
+
|
|
3
|
+
const RESERVED_PARENT_ENV_KEYS = new Set(['CLAUDECODE', LAUNCH_CONFIG_ENV]);
|
|
4
|
+
const ANTHROPIC_KEY_SET = new Set(LAUNCH_ANTHROPIC_ENV_KEYS);
|
|
5
|
+
|
|
6
|
+
// Claude Code internal env vars that carry session/auth state between instances.
|
|
7
|
+
// These MUST be stripped to prevent a running default instance from leaking
|
|
8
|
+
// credentials into a profile-specific launch.
|
|
9
|
+
const CLAUDE_SESSION_ENV_PREFIXES = ['CLAUDE_CODE_OAUTH', 'CLAUDE_CODE_SESSION'];
|
|
10
|
+
const CLAUDE_SESSION_ENV_EXACT = new Set([
|
|
11
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
12
|
+
'CLAUDE_CODE_REMOTE',
|
|
13
|
+
'CLAUDE_CODE_REMOTE_SESSION_ID',
|
|
14
|
+
'CLAUDE_CODE_CONTAINER_ID',
|
|
15
|
+
'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const toUpperKey = (key) => String(key || '').toUpperCase();
|
|
19
|
+
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
20
|
+
|
|
21
|
+
const sanitizeValue = (value) => {
|
|
22
|
+
if (value === undefined || value === null) return undefined;
|
|
23
|
+
return String(value);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const collectAnthropicValues = (source = {}) => {
|
|
27
|
+
const collected = {};
|
|
28
|
+
|
|
29
|
+
for (const [rawKey, rawValue] of Object.entries(source || {})) {
|
|
30
|
+
const key = toUpperKey(rawKey);
|
|
31
|
+
if (!ANTHROPIC_KEY_SET.has(key)) continue;
|
|
32
|
+
|
|
33
|
+
const value = sanitizeValue(rawValue);
|
|
34
|
+
if (value === undefined) continue;
|
|
35
|
+
|
|
36
|
+
collected[key] = value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return collected;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const sanitizeInheritedLaunchEnv = (env = {}) => {
|
|
43
|
+
const sanitized = {};
|
|
44
|
+
|
|
45
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
46
|
+
if (RESERVED_PARENT_ENV_KEYS.has(toUpperKey(key))) continue;
|
|
47
|
+
sanitized[key] = value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return sanitized;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const stripAnthropicKeys = (env = {}) => {
|
|
54
|
+
const sanitized = {};
|
|
55
|
+
|
|
56
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
57
|
+
const upper = toUpperKey(key);
|
|
58
|
+
// Strip all ANTHROPIC_* keys (will be re-added from profile settings)
|
|
59
|
+
if (upper.startsWith('ANTHROPIC_')) continue;
|
|
60
|
+
// Strip Claude Code session/auth env vars to prevent cross-instance leakage
|
|
61
|
+
if (CLAUDE_SESSION_ENV_EXACT.has(upper)) continue;
|
|
62
|
+
if (CLAUDE_SESSION_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix))) continue;
|
|
63
|
+
sanitized[key] = value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return sanitized;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const parseSettingsLaunchEnv = (settingsInput) => {
|
|
70
|
+
if (!settingsInput) return {};
|
|
71
|
+
|
|
72
|
+
let settings = settingsInput;
|
|
73
|
+
|
|
74
|
+
if (typeof settingsInput === 'string') {
|
|
75
|
+
settings = JSON.parse(settingsInput);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const envBlock = settings.env;
|
|
83
|
+
if (!envBlock || typeof envBlock !== 'object' || Array.isArray(envBlock)) {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return collectAnthropicValues(envBlock);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const stripUnquotedInlineComment = (value) => {
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
if (!trimmed) return trimmed;
|
|
93
|
+
|
|
94
|
+
if (trimmed.startsWith("'") || trimmed.startsWith('"')) {
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const commentIndex = trimmed.indexOf('#');
|
|
99
|
+
if (commentIndex === -1) return trimmed;
|
|
100
|
+
|
|
101
|
+
return trimmed.slice(0, commentIndex).trimEnd();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const unquoteDotEnvValue = (value) => {
|
|
105
|
+
const withoutInlineComment = stripUnquotedInlineComment(value);
|
|
106
|
+
const trimmed = withoutInlineComment.trim();
|
|
107
|
+
if (trimmed.length < 2) return trimmed;
|
|
108
|
+
|
|
109
|
+
const startsWithSingle = trimmed.startsWith("'") && trimmed.endsWith("'");
|
|
110
|
+
const startsWithDouble = trimmed.startsWith('"') && trimmed.endsWith('"');
|
|
111
|
+
|
|
112
|
+
if (!startsWithSingle && !startsWithDouble) return trimmed;
|
|
113
|
+
|
|
114
|
+
return trimmed.slice(1, -1);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const parseDotEnvLaunchEnv = (content = '') => {
|
|
118
|
+
const parsed = {};
|
|
119
|
+
|
|
120
|
+
for (const rawLine of String(content).split(/\r?\n/)) {
|
|
121
|
+
const line = rawLine.trim();
|
|
122
|
+
if (!line || line.startsWith('#')) continue;
|
|
123
|
+
|
|
124
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
125
|
+
if (!match) continue;
|
|
126
|
+
|
|
127
|
+
const [, rawKey, rawValue] = match;
|
|
128
|
+
const key = toUpperKey(rawKey);
|
|
129
|
+
if (!ANTHROPIC_KEY_SET.has(key)) continue;
|
|
130
|
+
|
|
131
|
+
parsed[key] = unquoteDotEnvValue(rawValue);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return parsed;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const resolveAnthropicValuesByPrecedence = ({ parentValues, dotEnvValues, settingsValues, launchOverrides }) => {
|
|
138
|
+
// Highest -> lowest precedence:
|
|
139
|
+
// launchOverrides > profile settings.env > profile .env (allowlist) > parent env
|
|
140
|
+
const resolvedValues = {};
|
|
141
|
+
const resolvedSources = {};
|
|
142
|
+
|
|
143
|
+
const sources = [
|
|
144
|
+
['parent', parentValues],
|
|
145
|
+
['profile-dotenv', dotEnvValues],
|
|
146
|
+
['profile-settings', settingsValues],
|
|
147
|
+
['launch-override', launchOverrides],
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
for (const [sourceName, sourceValues] of sources) {
|
|
151
|
+
for (const key of LAUNCH_ANTHROPIC_ENV_KEYS) {
|
|
152
|
+
if (!hasOwn(sourceValues, key)) continue;
|
|
153
|
+
resolvedValues[key] = sourceValues[key];
|
|
154
|
+
resolvedSources[key] = sourceName;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { resolvedValues, resolvedSources };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const buildEffectiveLaunchEnv = ({
|
|
162
|
+
parentEnv = process.env,
|
|
163
|
+
profileSettingsEnv = {},
|
|
164
|
+
profileDotEnvEnv = {},
|
|
165
|
+
launchOverrides = {},
|
|
166
|
+
} = {}) => {
|
|
167
|
+
const sanitizedParentEnv = sanitizeInheritedLaunchEnv(parentEnv);
|
|
168
|
+
const baseEnv = stripAnthropicKeys(sanitizedParentEnv);
|
|
169
|
+
|
|
170
|
+
const parentValues = collectAnthropicValues(parentEnv);
|
|
171
|
+
const dotEnvValues = collectAnthropicValues(profileDotEnvEnv);
|
|
172
|
+
const settingsValues = collectAnthropicValues(profileSettingsEnv);
|
|
173
|
+
const overrideValues = collectAnthropicValues(launchOverrides);
|
|
174
|
+
|
|
175
|
+
const { resolvedValues, resolvedSources } = resolveAnthropicValuesByPrecedence({
|
|
176
|
+
parentValues,
|
|
177
|
+
dotEnvValues,
|
|
178
|
+
settingsValues,
|
|
179
|
+
launchOverrides: overrideValues,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
launchEnv: {
|
|
184
|
+
...baseEnv,
|
|
185
|
+
...resolvedValues,
|
|
186
|
+
},
|
|
187
|
+
diagnostics: {
|
|
188
|
+
anthropicKeys: Object.keys(resolvedValues),
|
|
189
|
+
anthropicKeySources: resolvedSources,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
};
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export const isWindows = process.platform === 'win32';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a process with the given name is running.
|
|
7
|
+
* Windows: uses tasklist. Unix: uses pgrep.
|
|
8
|
+
*/
|
|
9
|
+
export const findProcess = (name) => {
|
|
10
|
+
try {
|
|
11
|
+
if (isWindows) {
|
|
12
|
+
const result = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${name}.exe`, '/NH'], {
|
|
13
|
+
encoding: 'utf-8',
|
|
14
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
15
|
+
});
|
|
16
|
+
return result.includes(`${name}.exe`);
|
|
17
|
+
} else {
|
|
18
|
+
const result = execFileSync('pgrep', ['-x', name], { encoding: 'utf-8' });
|
|
19
|
+
return result.trim().length > 0;
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
package/src/profile-store.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
PROFILES_DIR,
|
|
5
|
+
ACTIVE_FILE,
|
|
6
|
+
PROFILES_META,
|
|
7
|
+
CLAUDE_DIR,
|
|
8
|
+
DEFAULT_PROFILE,
|
|
9
|
+
PROFILES_SCHEMA_VERSION,
|
|
10
|
+
RUNTIMES_DIR,
|
|
11
|
+
} from './constants.js';
|
|
4
12
|
|
|
5
13
|
export const ensureProfilesDir = () => {
|
|
6
14
|
if (!existsSync(PROFILES_DIR)) {
|
|
@@ -8,15 +16,69 @@ export const ensureProfilesDir = () => {
|
|
|
8
16
|
}
|
|
9
17
|
};
|
|
10
18
|
|
|
19
|
+
const normalizeProfileMeta = (name, meta = {}) => {
|
|
20
|
+
const created = typeof meta.created === 'string' ? meta.created : new Date().toISOString();
|
|
21
|
+
const description = typeof meta.description === 'string' ? meta.description : '';
|
|
22
|
+
const mode =
|
|
23
|
+
meta.mode === 'legacy' || meta.mode === 'account-session'
|
|
24
|
+
? meta.mode
|
|
25
|
+
: name === DEFAULT_PROFILE
|
|
26
|
+
? 'legacy'
|
|
27
|
+
: 'account-session';
|
|
28
|
+
const runtimeDir = typeof meta.runtimeDir === 'string' && meta.runtimeDir ? meta.runtimeDir : getRuntimeDir(name);
|
|
29
|
+
const runtimeInitializedAt = typeof meta.runtimeInitializedAt === 'string' ? meta.runtimeInitializedAt : null;
|
|
30
|
+
const lastLaunchAt = typeof meta.lastLaunchAt === 'string' ? meta.lastLaunchAt : null;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...meta,
|
|
34
|
+
created,
|
|
35
|
+
description,
|
|
36
|
+
mode,
|
|
37
|
+
runtimeDir,
|
|
38
|
+
runtimeInitializedAt,
|
|
39
|
+
lastLaunchAt,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const normalizeProfiles = (raw) => {
|
|
44
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
45
|
+
const legacyProfiles = raw.profiles && typeof raw.profiles === 'object' ? raw.profiles : raw;
|
|
46
|
+
|
|
47
|
+
const normalized = {};
|
|
48
|
+
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
|
49
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue;
|
|
50
|
+
normalized[name] = normalizeProfileMeta(name, meta);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return normalized;
|
|
54
|
+
};
|
|
55
|
+
|
|
11
56
|
export const readProfiles = () => {
|
|
12
57
|
const metaPath = join(PROFILES_DIR, PROFILES_META);
|
|
13
58
|
if (!existsSync(metaPath)) return {};
|
|
14
|
-
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const raw = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
62
|
+
return normalizeProfiles(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
15
66
|
};
|
|
16
67
|
|
|
17
|
-
export const writeProfiles = (
|
|
68
|
+
export const writeProfiles = (profiles) => {
|
|
18
69
|
ensureProfilesDir();
|
|
19
|
-
|
|
70
|
+
|
|
71
|
+
const normalized = {};
|
|
72
|
+
for (const [name, meta] of Object.entries(profiles || {})) {
|
|
73
|
+
normalized[name] = normalizeProfileMeta(name, meta);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const payload = {
|
|
77
|
+
version: PROFILES_SCHEMA_VERSION,
|
|
78
|
+
profiles: normalized,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
writeFileSync(join(PROFILES_DIR, PROFILES_META), JSON.stringify(payload, null, 2) + '\n');
|
|
20
82
|
};
|
|
21
83
|
|
|
22
84
|
export const getActive = () => {
|
|
@@ -30,13 +92,35 @@ export const setActive = (name) => {
|
|
|
30
92
|
writeFileSync(join(PROFILES_DIR, ACTIVE_FILE), name + '\n');
|
|
31
93
|
};
|
|
32
94
|
|
|
95
|
+
export const clearActive = () => {
|
|
96
|
+
const activePath = join(PROFILES_DIR, ACTIVE_FILE);
|
|
97
|
+
try {
|
|
98
|
+
if (existsSync(activePath)) unlinkSync(activePath);
|
|
99
|
+
} catch {
|
|
100
|
+
// Best effort
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const PREVIOUS_FILE = '.previous';
|
|
105
|
+
|
|
106
|
+
export const getPrevious = () => {
|
|
107
|
+
const prevPath = join(PROFILES_DIR, PREVIOUS_FILE);
|
|
108
|
+
if (!existsSync(prevPath)) return null;
|
|
109
|
+
return readFileSync(prevPath, 'utf-8').trim() || null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const setPrevious = (name) => {
|
|
113
|
+
ensureProfilesDir();
|
|
114
|
+
writeFileSync(join(PROFILES_DIR, PREVIOUS_FILE), name + '\n');
|
|
115
|
+
};
|
|
116
|
+
|
|
33
117
|
export const addProfile = (name, metadata = {}) => {
|
|
34
118
|
const profiles = readProfiles();
|
|
35
|
-
profiles[name] = {
|
|
119
|
+
profiles[name] = normalizeProfileMeta(name, {
|
|
36
120
|
created: new Date().toISOString(),
|
|
37
121
|
description: '',
|
|
38
122
|
...metadata,
|
|
39
|
-
};
|
|
123
|
+
});
|
|
40
124
|
writeProfiles(profiles);
|
|
41
125
|
};
|
|
42
126
|
|
|
@@ -47,6 +131,7 @@ export const removeProfile = (name) => {
|
|
|
47
131
|
};
|
|
48
132
|
|
|
49
133
|
export const profileExists = (name) => {
|
|
134
|
+
if (name === DEFAULT_PROFILE) return true;
|
|
50
135
|
return existsSync(getProfileDir(name));
|
|
51
136
|
};
|
|
52
137
|
|
|
@@ -63,6 +148,53 @@ export const getProfileDir = (name) => {
|
|
|
63
148
|
return join(PROFILES_DIR, name);
|
|
64
149
|
};
|
|
65
150
|
|
|
151
|
+
export const getRuntimeDir = (name) => {
|
|
152
|
+
validateName(name);
|
|
153
|
+
return join(RUNTIMES_DIR, name);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const getProfileMeta = (name) => {
|
|
157
|
+
const profiles = readProfiles();
|
|
158
|
+
return profiles[name] || null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const updateProfileMeta = (name, patch) => {
|
|
162
|
+
const profiles = readProfiles();
|
|
163
|
+
if (!profiles[name]) return null;
|
|
164
|
+
|
|
165
|
+
const next = typeof patch === 'function' ? patch({ ...profiles[name] }) : { ...profiles[name], ...patch };
|
|
166
|
+
profiles[name] = normalizeProfileMeta(name, next);
|
|
167
|
+
writeProfiles(profiles);
|
|
168
|
+
return profiles[name];
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const markRuntimeInitialized = (name, runtimeDir) => {
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
return updateProfileMeta(name, {
|
|
174
|
+
runtimeDir,
|
|
175
|
+
runtimeInitializedAt: now,
|
|
176
|
+
lastLaunchAt: now,
|
|
177
|
+
mode: 'account-session',
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const markProfileLaunched = (name) => {
|
|
182
|
+
return updateProfileMeta(name, {
|
|
183
|
+
lastLaunchAt: new Date().toISOString(),
|
|
184
|
+
mode: 'account-session',
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
66
188
|
export const listProfileNames = () => {
|
|
67
189
|
return Object.keys(readProfiles());
|
|
68
190
|
};
|
|
191
|
+
|
|
192
|
+
// Returns the directory containing a profile's actual files.
|
|
193
|
+
// If profile is active, items were moved to CLAUDE_DIR during switch.
|
|
194
|
+
// If profile is not active, items are in profileDir.
|
|
195
|
+
export const getEffectiveDir = (name) => {
|
|
196
|
+
if (name === DEFAULT_PROFILE) return CLAUDE_DIR;
|
|
197
|
+
const active = getActive();
|
|
198
|
+
if (active === name) return CLAUDE_DIR;
|
|
199
|
+
return getProfileDir(name);
|
|
200
|
+
};
|
package/src/profile-validator.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { SOURCE_FILE,
|
|
3
|
+
import { SOURCE_FILE, MANAGED_ITEMS } from './constants.js';
|
|
4
4
|
|
|
5
5
|
// Validate a profile directory
|
|
6
6
|
export const validateProfile = (profileDir) => {
|
|
@@ -12,13 +12,13 @@ export const validateProfile = (profileDir) => {
|
|
|
12
12
|
|
|
13
13
|
const sourcePath = join(profileDir, SOURCE_FILE);
|
|
14
14
|
if (!existsSync(sourcePath)) {
|
|
15
|
-
errors.push('Missing source.json — no
|
|
15
|
+
errors.push('Missing source.json — no managed items defined');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
return { valid: errors.length === 0, errors };
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
// Check all
|
|
21
|
+
// Check all item targets in sourceMap actually exist on disk
|
|
22
22
|
export const validateSourceTargets = (sourceMap) => {
|
|
23
23
|
const errors = [];
|
|
24
24
|
for (const [item, target] of Object.entries(sourceMap)) {
|
|
@@ -29,17 +29,15 @@ export const validateSourceTargets = (sourceMap) => {
|
|
|
29
29
|
return { valid: errors.length === 0, errors };
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
// List what files
|
|
32
|
+
// List what files a profile contains
|
|
33
33
|
export const listManagedItems = (profileDir) => {
|
|
34
|
-
if (!existsSync(profileDir)) return {
|
|
34
|
+
if (!existsSync(profileDir)) return { files: [], dirs: [] };
|
|
35
35
|
|
|
36
36
|
const entries = readdirSync(profileDir, { withFileTypes: true });
|
|
37
|
-
const items = {
|
|
37
|
+
const items = { files: [], dirs: [] };
|
|
38
38
|
|
|
39
39
|
for (const entry of entries) {
|
|
40
|
-
if (entry.
|
|
41
|
-
items.files.push(entry.name);
|
|
42
|
-
} else if (entry.isDirectory()) {
|
|
40
|
+
if (entry.isDirectory()) {
|
|
43
41
|
items.dirs.push(entry.name);
|
|
44
42
|
} else {
|
|
45
43
|
items.files.push(entry.name);
|