cli-profile-manager 0.0.1
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/LICENSE +21 -0
- package/README.md +176 -0
- package/docs/devcontainer-setup.md +85 -0
- package/index.json +51 -0
- package/package.json +38 -0
- package/scripts/install-profile.mjs +87 -0
- package/src/cli.js +165 -0
- package/src/commands/local.js +307 -0
- package/src/commands/marketplace.js +403 -0
- package/src/commands/publish.js +252 -0
- package/src/utils/auth.js +403 -0
- package/src/utils/config.js +102 -0
- package/src/utils/snapshot.js +421 -0
- package/test/cli.test.js +22 -0
- package/test/config.test.js +15 -0
- package/test/references.test.js +39 -0
- package/test/snapshot.test.js +61 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
const HOME = homedir();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Find Claude directory - checks project root first (codespaces), then home
|
|
9
|
+
*/
|
|
10
|
+
function findClaudeDir() {
|
|
11
|
+
const candidates = [
|
|
12
|
+
join(process.cwd(), '.claude'), // Project root (codespaces, dev environments)
|
|
13
|
+
join(HOME, '.claude'), // Home directory (standard local install)
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
for (const dir of candidates) {
|
|
17
|
+
if (existsSync(dir)) {
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Default to home directory if not found
|
|
23
|
+
return join(HOME, '.claude');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Default paths
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
claudeDir: findClaudeDir(),
|
|
29
|
+
profilesDir: join(HOME, '.claude-profiles'),
|
|
30
|
+
cacheDir: join(HOME, '.claude-profiles', '.cache'),
|
|
31
|
+
configFile: join(HOME, '.claude-profiles', 'config.json'),
|
|
32
|
+
marketplaceRepo: 'brrichards/cli-profile-manager'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure required directories exist
|
|
37
|
+
*/
|
|
38
|
+
export function ensureDirs() {
|
|
39
|
+
const dirs = [DEFAULTS.profilesDir, DEFAULTS.cacheDir];
|
|
40
|
+
for (const dir of dirs) {
|
|
41
|
+
if (!existsSync(dir)) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get current configuration
|
|
49
|
+
*/
|
|
50
|
+
export async function getConfig() {
|
|
51
|
+
ensureDirs();
|
|
52
|
+
|
|
53
|
+
let userConfig = {};
|
|
54
|
+
|
|
55
|
+
if (existsSync(DEFAULTS.configFile)) {
|
|
56
|
+
try {
|
|
57
|
+
userConfig = JSON.parse(readFileSync(DEFAULTS.configFile, 'utf-8'));
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Ignore invalid config
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...DEFAULTS,
|
|
65
|
+
...userConfig
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Update configuration
|
|
71
|
+
*/
|
|
72
|
+
export async function updateConfig(updates) {
|
|
73
|
+
ensureDirs();
|
|
74
|
+
|
|
75
|
+
const current = await getConfig();
|
|
76
|
+
const newConfig = { ...current, ...updates };
|
|
77
|
+
|
|
78
|
+
// Only save user-configurable options
|
|
79
|
+
const toSave = {
|
|
80
|
+
marketplaceRepo: newConfig.marketplaceRepo
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
writeFileSync(DEFAULTS.configFile, JSON.stringify(toSave, null, 2));
|
|
84
|
+
|
|
85
|
+
return newConfig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the path for a local profile
|
|
90
|
+
*/
|
|
91
|
+
export function getProfilePath(name) {
|
|
92
|
+
return join(DEFAULTS.profilesDir, name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if Claude directory exists
|
|
97
|
+
*/
|
|
98
|
+
export function claudeDirExists() {
|
|
99
|
+
return existsSync(DEFAULTS.claudeDir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { DEFAULTS };
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, cpSync, rmSync } from 'fs';
|
|
2
|
+
import { join, dirname, sep } from 'path';
|
|
3
|
+
import { getConfig, DEFAULTS } from './config.js';
|
|
4
|
+
|
|
5
|
+
// Files/patterns to exclude by default (secrets, caches, infra)
|
|
6
|
+
const DEFAULT_EXCLUDES = [
|
|
7
|
+
'.credentials',
|
|
8
|
+
'.auth',
|
|
9
|
+
'*.key',
|
|
10
|
+
'*.pem',
|
|
11
|
+
'*.secret',
|
|
12
|
+
'oauth_token*',
|
|
13
|
+
'.cache',
|
|
14
|
+
'node_modules',
|
|
15
|
+
'.git',
|
|
16
|
+
// Claude Code plugin infrastructure -- present in every install,
|
|
17
|
+
// not user-authored content. Excluded from snapshots and preserved
|
|
18
|
+
// during profile installs (cleanProfileContent).
|
|
19
|
+
'plugins/cache',
|
|
20
|
+
'plugins/install-counts-cache',
|
|
21
|
+
'plugins/installed_plugins',
|
|
22
|
+
'plugins/known_marketplaces',
|
|
23
|
+
'plugins/marketplaces'
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Plugin subdirectories that are Claude Code infrastructure.
|
|
27
|
+
// These must be preserved when cleaning profile content.
|
|
28
|
+
const PLUGIN_INFRA_DIRS = [
|
|
29
|
+
'cache',
|
|
30
|
+
'install-counts-cache',
|
|
31
|
+
'installed_plugins',
|
|
32
|
+
'known_marketplaces',
|
|
33
|
+
'marketplaces'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Files that are safe to include (functional customizations only)
|
|
37
|
+
const SAFE_INCLUDES = [
|
|
38
|
+
'CLAUDE.md',
|
|
39
|
+
'commands',
|
|
40
|
+
'commands/**',
|
|
41
|
+
'skills',
|
|
42
|
+
'skills/**',
|
|
43
|
+
'hooks',
|
|
44
|
+
'hooks/**',
|
|
45
|
+
'plugins',
|
|
46
|
+
'plugins/**',
|
|
47
|
+
'mcp.json',
|
|
48
|
+
'mcp_servers',
|
|
49
|
+
'mcp_servers/**',
|
|
50
|
+
'agents',
|
|
51
|
+
'agents/**'
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a snapshot of the .claude folder by copying files directly
|
|
56
|
+
*/
|
|
57
|
+
export async function createSnapshot(profileName, options = {}) {
|
|
58
|
+
const config = await getConfig();
|
|
59
|
+
const claudeDir = config.claudeDir;
|
|
60
|
+
const profileDir = join(config.profilesDir, profileName);
|
|
61
|
+
|
|
62
|
+
if (!existsSync(claudeDir)) {
|
|
63
|
+
throw new Error(`Claude directory not found: ${claudeDir}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create profile directory
|
|
67
|
+
if (existsSync(profileDir)) {
|
|
68
|
+
throw new Error(`Profile "${profileName}" already exists. Use a different name or delete the existing one.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
mkdirSync(profileDir, { recursive: true });
|
|
72
|
+
|
|
73
|
+
const metadataPath = join(profileDir, 'profile.json');
|
|
74
|
+
|
|
75
|
+
// Create metadata
|
|
76
|
+
const metadata = {
|
|
77
|
+
name: profileName,
|
|
78
|
+
version: '1.0.0',
|
|
79
|
+
description: options.description || '',
|
|
80
|
+
tags: options.tags ? options.tags.split(',').map(t => t.trim()) : [],
|
|
81
|
+
createdAt: new Date().toISOString(),
|
|
82
|
+
claudeVersion: await getClaudeVersion(),
|
|
83
|
+
platform: process.platform,
|
|
84
|
+
includesSecrets: options.includeSecrets || false,
|
|
85
|
+
files: []
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Get list of files to include
|
|
89
|
+
const files = getFilesToArchive(claudeDir, options.includeSecrets);
|
|
90
|
+
metadata.files = files;
|
|
91
|
+
|
|
92
|
+
// Copy each file into the profile directory
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const srcPath = join(claudeDir, file);
|
|
95
|
+
const destPath = join(profileDir, file);
|
|
96
|
+
|
|
97
|
+
// Ensure parent directory exists
|
|
98
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
99
|
+
|
|
100
|
+
const content = readFileSync(srcPath);
|
|
101
|
+
writeFileSync(destPath, content);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Derive structured contents from file list
|
|
105
|
+
metadata.contents = deriveContentsWithMcp(metadata.files, claudeDir);
|
|
106
|
+
|
|
107
|
+
// Save metadata
|
|
108
|
+
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
|
109
|
+
|
|
110
|
+
return { profileDir, metadata };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Derive a structured contents summary from a list of file paths.
|
|
115
|
+
* Returns an object with category keys mapping to arrays of item names.
|
|
116
|
+
*/
|
|
117
|
+
export function deriveContents(files) {
|
|
118
|
+
const contents = {};
|
|
119
|
+
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
const normalized = file.split(sep).join('/');
|
|
122
|
+
|
|
123
|
+
if (normalized === 'CLAUDE.md') {
|
|
124
|
+
if (!contents.instructions) contents.instructions = [];
|
|
125
|
+
contents.instructions.push('CLAUDE.md');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (normalized === 'mcp.json') {
|
|
130
|
+
if (!contents.mcp) contents.mcp = [];
|
|
131
|
+
contents.mcp.push('mcp.json');
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parts = normalized.split('/');
|
|
136
|
+
if (parts.length >= 2) {
|
|
137
|
+
const category = parts[0];
|
|
138
|
+
const itemName = parts[1].replace(/\.[^.]+$/, ''); // strip extension
|
|
139
|
+
if (!contents[category]) contents[category] = [];
|
|
140
|
+
if (!contents[category].includes(itemName)) {
|
|
141
|
+
contents[category].push(itemName);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return contents;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Derive contents and try to enrich MCP server names from the actual mcp.json file
|
|
151
|
+
*/
|
|
152
|
+
function deriveContentsWithMcp(files, claudeDir) {
|
|
153
|
+
const contents = deriveContents(files);
|
|
154
|
+
|
|
155
|
+
if (contents.mcp && claudeDir) {
|
|
156
|
+
try {
|
|
157
|
+
const mcpPath = join(claudeDir, 'mcp.json');
|
|
158
|
+
if (existsSync(mcpPath)) {
|
|
159
|
+
const mcpData = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
160
|
+
const serverNames = Object.keys(mcpData.mcpServers || mcpData);
|
|
161
|
+
if (serverNames.length > 0) {
|
|
162
|
+
contents.mcp = serverNames;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Keep the fallback
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return contents;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get list of files to archive (using allowlist approach)
|
|
175
|
+
*/
|
|
176
|
+
export function getFilesToArchive(dir, includeSecrets = false) {
|
|
177
|
+
const files = [];
|
|
178
|
+
const excludes = includeSecrets ? [] : DEFAULT_EXCLUDES;
|
|
179
|
+
|
|
180
|
+
function walk(currentDir, relativePath = '') {
|
|
181
|
+
const entries = readdirSync(currentDir);
|
|
182
|
+
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
const fullPath = join(currentDir, entry);
|
|
185
|
+
const relPath = relativePath ? join(relativePath, entry) : entry;
|
|
186
|
+
|
|
187
|
+
if (!isAllowed(entry, relPath)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (shouldExclude(entry, relPath, excludes)) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stat = statSync(fullPath);
|
|
196
|
+
|
|
197
|
+
if (stat.isDirectory()) {
|
|
198
|
+
walk(fullPath, relPath);
|
|
199
|
+
} else {
|
|
200
|
+
// Always use forward slashes for portable metadata
|
|
201
|
+
files.push(relPath.split(sep).join('/'));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
walk(dir);
|
|
207
|
+
return files;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if a file/folder is in the allowlist
|
|
212
|
+
*/
|
|
213
|
+
function isAllowed(name, path) {
|
|
214
|
+
const normalizedPath = path.split(sep).join('/');
|
|
215
|
+
|
|
216
|
+
for (const pattern of SAFE_INCLUDES) {
|
|
217
|
+
if (pattern.endsWith('/**')) {
|
|
218
|
+
const dirName = pattern.slice(0, -3);
|
|
219
|
+
if (normalizedPath.startsWith(dirName + '/') || normalizedPath === dirName) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
} else if (name === pattern || normalizedPath === pattern) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if a file/folder should be excluded
|
|
231
|
+
*/
|
|
232
|
+
function shouldExclude(name, path, excludes) {
|
|
233
|
+
const normalizedPath = path.split(sep).join('/');
|
|
234
|
+
for (const pattern of excludes) {
|
|
235
|
+
if (pattern.startsWith('*.')) {
|
|
236
|
+
const ext = pattern.slice(1);
|
|
237
|
+
if (name.endsWith(ext)) return true;
|
|
238
|
+
} else if (name === pattern || normalizedPath === pattern) {
|
|
239
|
+
return true;
|
|
240
|
+
} else if (pattern.endsWith('*') && name.startsWith(pattern.slice(0, -1))) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extract a profile to the .claude folder by copying files directly.
|
|
249
|
+
* Uses a merge strategy to work even when Claude Code is running.
|
|
250
|
+
*/
|
|
251
|
+
export async function extractSnapshot(profileName, options = {}) {
|
|
252
|
+
const config = await getConfig();
|
|
253
|
+
const profileDir = join(config.profilesDir, profileName);
|
|
254
|
+
const claudeDir = config.claudeDir;
|
|
255
|
+
|
|
256
|
+
if (!existsSync(profileDir) || !existsSync(join(profileDir, 'profile.json'))) {
|
|
257
|
+
throw new Error(`Profile "${profileName}" not found or corrupted`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Backup existing .claude if requested
|
|
261
|
+
if (options.backup && existsSync(claudeDir)) {
|
|
262
|
+
const backupName = `.claude-backup-${Date.now()}`;
|
|
263
|
+
const backupPath = join(DEFAULTS.profilesDir, backupName);
|
|
264
|
+
cpSync(claudeDir, backupPath, { recursive: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Check if we need force flag
|
|
268
|
+
if (existsSync(claudeDir) && !options.force) {
|
|
269
|
+
throw new Error('Claude directory exists. Use --force to overwrite or --backup to save current config.');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Ensure .claude directory exists
|
|
273
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
274
|
+
|
|
275
|
+
// Clean out old profile content before installing new files
|
|
276
|
+
cleanProfileContent(claudeDir);
|
|
277
|
+
|
|
278
|
+
// Copy profile files (excluding profile.json) into .claude
|
|
279
|
+
copyProfileFiles(profileDir, claudeDir);
|
|
280
|
+
|
|
281
|
+
return { claudeDir };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Remove existing profile content from a .claude directory.
|
|
286
|
+
* Only removes items in the SAFE_INCLUDES allowlist (commands/, hooks/, etc.)
|
|
287
|
+
* so non-profile files (settings, credentials) are preserved.
|
|
288
|
+
*/
|
|
289
|
+
export function cleanProfileContent(claudeDir) {
|
|
290
|
+
// Directories to wipe entirely
|
|
291
|
+
const contentDirs = ['commands', 'skills', 'hooks', 'mcp_servers', 'agents'];
|
|
292
|
+
for (const dir of contentDirs) {
|
|
293
|
+
const dirPath = join(claudeDir, dir);
|
|
294
|
+
if (existsSync(dirPath)) {
|
|
295
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Plugins: only remove user-authored content, preserve Claude Code infra
|
|
300
|
+
const pluginsDir = join(claudeDir, 'plugins');
|
|
301
|
+
if (existsSync(pluginsDir)) {
|
|
302
|
+
for (const entry of readdirSync(pluginsDir)) {
|
|
303
|
+
if (!PLUGIN_INFRA_DIRS.includes(entry)) {
|
|
304
|
+
rmSync(join(pluginsDir, entry), { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Individual files to remove
|
|
310
|
+
const contentFiles = ['CLAUDE.md', 'mcp.json'];
|
|
311
|
+
for (const file of contentFiles) {
|
|
312
|
+
const filePath = join(claudeDir, file);
|
|
313
|
+
if (existsSync(filePath)) {
|
|
314
|
+
rmSync(filePath, { force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Copy profile content files (not profile.json) from source to destination.
|
|
321
|
+
*/
|
|
322
|
+
function copyProfileFiles(srcDir, destDir) {
|
|
323
|
+
const entries = readdirSync(srcDir, { withFileTypes: true });
|
|
324
|
+
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
// Skip profile.json -- it's metadata, not a profile file
|
|
327
|
+
if (entry.name === 'profile.json') continue;
|
|
328
|
+
|
|
329
|
+
const srcPath = join(srcDir, entry.name);
|
|
330
|
+
const destPath = join(destDir, entry.name);
|
|
331
|
+
|
|
332
|
+
if (entry.isDirectory()) {
|
|
333
|
+
mkdirSync(destPath, { recursive: true });
|
|
334
|
+
copyDirMerge(srcPath, destPath);
|
|
335
|
+
} else {
|
|
336
|
+
try {
|
|
337
|
+
const content = readFileSync(srcPath);
|
|
338
|
+
writeFileSync(destPath, content);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
if (err.code === 'EBUSY') {
|
|
341
|
+
throw new Error(`Cannot write to ${entry.name} - file is locked. Please close Claude Code and try again.`);
|
|
342
|
+
}
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Recursively copy/merge directory contents, overwriting files.
|
|
351
|
+
* This works even when the target directory has open file handles.
|
|
352
|
+
*/
|
|
353
|
+
export function copyDirMerge(src, dest) {
|
|
354
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
355
|
+
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const srcPath = join(src, entry.name);
|
|
358
|
+
const destPath = join(dest, entry.name);
|
|
359
|
+
|
|
360
|
+
if (entry.isDirectory()) {
|
|
361
|
+
mkdirSync(destPath, { recursive: true });
|
|
362
|
+
copyDirMerge(srcPath, destPath);
|
|
363
|
+
} else {
|
|
364
|
+
try {
|
|
365
|
+
const content = readFileSync(srcPath);
|
|
366
|
+
writeFileSync(destPath, content);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err.code === 'EBUSY') {
|
|
369
|
+
throw new Error(`Cannot write to ${entry.name} - file is locked. Please close Claude Code and try again.`);
|
|
370
|
+
}
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Get Claude CLI version if installed
|
|
379
|
+
*/
|
|
380
|
+
async function getClaudeVersion() {
|
|
381
|
+
try {
|
|
382
|
+
const { execSync } = await import('child_process');
|
|
383
|
+
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
|
|
384
|
+
return version;
|
|
385
|
+
} catch {
|
|
386
|
+
return 'unknown';
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Read profile metadata
|
|
392
|
+
*/
|
|
393
|
+
export function readProfileMetadata(profileName) {
|
|
394
|
+
const config = DEFAULTS;
|
|
395
|
+
const metadataPath = join(config.profilesDir, profileName, 'profile.json');
|
|
396
|
+
|
|
397
|
+
if (!existsSync(metadataPath)) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return JSON.parse(readFileSync(metadataPath, 'utf-8'));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* List all local profiles
|
|
406
|
+
*/
|
|
407
|
+
export function listLocalProfileNames() {
|
|
408
|
+
const profilesDir = DEFAULTS.profilesDir;
|
|
409
|
+
|
|
410
|
+
if (!existsSync(profilesDir)) {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return readdirSync(profilesDir)
|
|
415
|
+
.filter(name => {
|
|
416
|
+
if (name.startsWith('.')) return false;
|
|
417
|
+
const profilePath = join(profilesDir, name);
|
|
418
|
+
const stat = statSync(profilePath);
|
|
419
|
+
return stat.isDirectory() && existsSync(join(profilePath, 'profile.json'));
|
|
420
|
+
});
|
|
421
|
+
}
|
package/test/cli.test.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
const CLI = join(import.meta.dirname, '..', 'src', 'cli.js');
|
|
7
|
+
|
|
8
|
+
describe('CLI smoke test', () => {
|
|
9
|
+
it('--help exits 0 and shows expected commands', () => {
|
|
10
|
+
const output = execSync(`node ${CLI} --help`, { encoding: 'utf-8' });
|
|
11
|
+
assert.ok(output.includes('save'), 'should list save command');
|
|
12
|
+
assert.ok(output.includes('load'), 'should list load command');
|
|
13
|
+
assert.ok(output.includes('install'), 'should list install command');
|
|
14
|
+
assert.ok(output.includes('publish'), 'should list publish command');
|
|
15
|
+
assert.ok(output.includes('list'), 'should list list command');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('--version outputs a version string', () => {
|
|
19
|
+
const output = execSync(`node ${CLI} --version`, { encoding: 'utf-8' });
|
|
20
|
+
assert.match(output.trim(), /^\d+\.\d+\.\d+$/);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
describe('config defaults', () => {
|
|
5
|
+
it('marketplaceRepo defaults to brrichards/cli-profile-manager', async () => {
|
|
6
|
+
const { DEFAULTS } = await import('../src/utils/config.js');
|
|
7
|
+
assert.strictEqual(DEFAULTS.marketplaceRepo, 'brrichards/cli-profile-manager');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('getConfig returns the new marketplace repo', async () => {
|
|
11
|
+
const { getConfig } = await import('../src/utils/config.js');
|
|
12
|
+
const config = await getConfig();
|
|
13
|
+
assert.strictEqual(config.marketplaceRepo, 'brrichards/cli-profile-manager');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
const ROOT = join(import.meta.dirname, '..');
|
|
7
|
+
const OLD_REPO = 'brennanr9/claude-profile-manager';
|
|
8
|
+
|
|
9
|
+
function collectFiles(dir, extensions) {
|
|
10
|
+
const results = [];
|
|
11
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
12
|
+
const full = join(dir, entry.name);
|
|
13
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
results.push(...collectFiles(full, extensions));
|
|
16
|
+
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
|
|
17
|
+
results.push(full);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('repo reference cleanup', () => {
|
|
24
|
+
const extensions = ['.js', '.mjs', '.json', '.md', '.yml'];
|
|
25
|
+
const files = collectFiles(ROOT, extensions);
|
|
26
|
+
|
|
27
|
+
it('no source files reference the old repo', () => {
|
|
28
|
+
const violations = [];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
// Skip test files themselves and package-lock
|
|
31
|
+
if (file.includes('/test/') || file.includes('package-lock')) continue;
|
|
32
|
+
const content = readFileSync(file, 'utf-8');
|
|
33
|
+
if (content.includes(OLD_REPO)) {
|
|
34
|
+
violations.push(file.replace(ROOT + '/', ''));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
assert.deepStrictEqual(violations, [], `Files still referencing ${OLD_REPO}: ${violations.join(', ')}`);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
describe('deriveContents', () => {
|
|
5
|
+
it('categorizes files into correct content buckets', async () => {
|
|
6
|
+
const { deriveContents } = await import('../src/utils/snapshot.js');
|
|
7
|
+
|
|
8
|
+
const files = [
|
|
9
|
+
'CLAUDE.md',
|
|
10
|
+
'commands/review.md',
|
|
11
|
+
'commands/test-gen.md',
|
|
12
|
+
'hooks/pre-commit.md',
|
|
13
|
+
'mcp.json',
|
|
14
|
+
'skills/debugging/SKILL.md'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const contents = deriveContents(files);
|
|
18
|
+
|
|
19
|
+
assert.deepStrictEqual(contents.instructions, ['CLAUDE.md']);
|
|
20
|
+
assert.deepStrictEqual(contents.commands, ['review', 'test-gen']);
|
|
21
|
+
assert.deepStrictEqual(contents.hooks, ['pre-commit']);
|
|
22
|
+
assert.deepStrictEqual(contents.mcp, ['mcp.json']);
|
|
23
|
+
assert.deepStrictEqual(contents.skills, ['debugging']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns empty object for empty file list', async () => {
|
|
27
|
+
const { deriveContents } = await import('../src/utils/snapshot.js');
|
|
28
|
+
const contents = deriveContents([]);
|
|
29
|
+
assert.deepStrictEqual(contents, {});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('getFilesToArchive', () => {
|
|
34
|
+
it('only includes files matching the allowlist', async () => {
|
|
35
|
+
const { getFilesToArchive } = await import('../src/utils/snapshot.js');
|
|
36
|
+
const { mkdirSync, writeFileSync, rmSync } = await import('fs');
|
|
37
|
+
const { join } = await import('path');
|
|
38
|
+
const { tmpdir } = await import('os');
|
|
39
|
+
|
|
40
|
+
// Create a temp .claude-like directory
|
|
41
|
+
const testDir = join(tmpdir(), `cpm-test-${Date.now()}`);
|
|
42
|
+
mkdirSync(join(testDir, 'commands'), { recursive: true });
|
|
43
|
+
mkdirSync(join(testDir, 'secrets'), { recursive: true });
|
|
44
|
+
writeFileSync(join(testDir, 'CLAUDE.md'), 'instructions');
|
|
45
|
+
writeFileSync(join(testDir, 'commands', 'foo.md'), 'command');
|
|
46
|
+
writeFileSync(join(testDir, 'secrets', 'key.pem'), 'secret');
|
|
47
|
+
writeFileSync(join(testDir, '.credentials'), 'creds');
|
|
48
|
+
writeFileSync(join(testDir, 'random.txt'), 'not allowed');
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const files = getFilesToArchive(testDir);
|
|
52
|
+
assert.ok(files.includes('CLAUDE.md'), 'should include CLAUDE.md');
|
|
53
|
+
assert.ok(files.includes('commands/foo.md'), 'should include commands/foo.md');
|
|
54
|
+
assert.ok(!files.includes('secrets/key.pem'), 'should not include secrets/');
|
|
55
|
+
assert.ok(!files.includes('.credentials'), 'should not include .credentials');
|
|
56
|
+
assert.ok(!files.includes('random.txt'), 'should not include random.txt');
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|