claude-profile-manager 1.0.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.
@@ -0,0 +1,184 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import inquirer from 'inquirer';
4
+ import { existsSync, readFileSync, mkdirSync, cpSync, writeFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { execSync } from 'child_process';
7
+ import { getConfig, updateConfig, getProfilePath } from '../utils/config.js';
8
+ import { readProfileMetadata } from '../utils/snapshot.js';
9
+
10
+ /**
11
+ * Publish a local profile to the marketplace
12
+ */
13
+ export async function publishProfile(name, options) {
14
+ const profilePath = getProfilePath(name);
15
+
16
+ if (!existsSync(profilePath)) {
17
+ console.log(chalk.red(`✗ Profile not found: ${name}`));
18
+ console.log(chalk.dim(' List local profiles with: cpm local'));
19
+ process.exit(1);
20
+ }
21
+
22
+ const metadata = readProfileMetadata(name);
23
+
24
+ if (!metadata) {
25
+ console.log(chalk.red('✗ Invalid profile: missing metadata'));
26
+ process.exit(1);
27
+ }
28
+
29
+ console.log('');
30
+ console.log(chalk.bold('Publish Profile to Marketplace'));
31
+ console.log(chalk.dim('─'.repeat(50)));
32
+ console.log('');
33
+
34
+ // Get GitHub username
35
+ let gitUsername;
36
+ try {
37
+ gitUsername = execSync('git config user.name', { encoding: 'utf-8' }).trim();
38
+ } catch {
39
+ gitUsername = '';
40
+ }
41
+
42
+ // Gather publication info
43
+ const answers = await inquirer.prompt([
44
+ {
45
+ type: 'input',
46
+ name: 'author',
47
+ message: 'Your GitHub username:',
48
+ default: gitUsername,
49
+ validate: (input) => {
50
+ if (!input || !/^[a-z0-9-]+$/i.test(input)) {
51
+ return 'Please enter a valid GitHub username';
52
+ }
53
+ return true;
54
+ }
55
+ },
56
+ {
57
+ type: 'input',
58
+ name: 'description',
59
+ message: 'Profile description:',
60
+ default: metadata.description || '',
61
+ validate: (input) => input.length >= 10 || 'Description must be at least 10 characters'
62
+ },
63
+ {
64
+ type: 'input',
65
+ name: 'tags',
66
+ message: 'Tags (comma-separated):',
67
+ default: metadata.tags?.join(', ') || '',
68
+ filter: (input) => input.split(',').map(t => t.trim().toLowerCase()).filter(Boolean)
69
+ },
70
+ {
71
+ type: 'confirm',
72
+ name: 'confirm',
73
+ message: 'Ready to publish?',
74
+ default: true
75
+ }
76
+ ]);
77
+
78
+ if (!answers.confirm) {
79
+ console.log(chalk.yellow('Aborted.'));
80
+ process.exit(0);
81
+ }
82
+
83
+ const config = await getConfig();
84
+
85
+ console.log('');
86
+ console.log(chalk.bold('📦 Publishing Profile'));
87
+ console.log('');
88
+ console.log(chalk.dim('To publish your profile, you need to:'));
89
+ console.log('');
90
+ console.log(chalk.cyan('1.') + ' Fork the marketplace repository:');
91
+ console.log(chalk.dim(` https://github.com/${config.marketplaceRepo}`));
92
+ console.log('');
93
+ console.log(chalk.cyan('2.') + ' Clone your fork locally:');
94
+ console.log(chalk.dim(` git clone https://github.com/${answers.author}/claude-profile-marketplace`));
95
+ console.log('');
96
+ console.log(chalk.cyan('3.') + ' Copy your profile to the profiles directory:');
97
+
98
+ const targetDir = `profiles/${answers.author}/${name}`;
99
+ console.log(chalk.dim(` mkdir -p ${targetDir}`));
100
+ console.log(chalk.dim(` cp -r ~/.claude-profiles/${name}/* ${targetDir}/`));
101
+ console.log('');
102
+ console.log(chalk.cyan('4.') + ' Update the profile metadata:');
103
+ console.log(chalk.dim(` Edit ${targetDir}/profile.json`));
104
+ console.log('');
105
+ console.log(chalk.cyan('5.') + ' Commit and push:');
106
+ console.log(chalk.dim(` git add .`));
107
+ console.log(chalk.dim(` git commit -m "Add profile: ${answers.author}/${name}"`));
108
+ console.log(chalk.dim(` git push origin main`));
109
+ console.log('');
110
+ console.log(chalk.cyan('6.') + ' Open a Pull Request on GitHub');
111
+ console.log('');
112
+
113
+ // Offer to prepare the files
114
+ const { prepare } = await inquirer.prompt([{
115
+ type: 'confirm',
116
+ name: 'prepare',
117
+ message: 'Would you like me to prepare the files for you?',
118
+ default: true
119
+ }]);
120
+
121
+ if (prepare) {
122
+ const exportDir = join(process.cwd(), 'publish-ready', answers.author, name);
123
+ mkdirSync(exportDir, { recursive: true });
124
+
125
+ // Copy profile files
126
+ cpSync(profilePath, exportDir, { recursive: true });
127
+
128
+ // Update metadata with author info
129
+ const updatedMetadata = {
130
+ ...metadata,
131
+ author: answers.author,
132
+ description: answers.description,
133
+ tags: answers.tags,
134
+ publishedAt: new Date().toISOString()
135
+ };
136
+
137
+ writeFileSync(
138
+ join(exportDir, 'profile.json'),
139
+ JSON.stringify(updatedMetadata, null, 2)
140
+ );
141
+
142
+ console.log('');
143
+ console.log(chalk.green('✓ Files prepared at:'));
144
+ console.log(chalk.cyan(` ${exportDir}`));
145
+ console.log('');
146
+ console.log(chalk.dim('Copy these files to your fork of the marketplace repository.'));
147
+ }
148
+
149
+ console.log('');
150
+ }
151
+
152
+ /**
153
+ * Set a custom marketplace repository
154
+ */
155
+ export async function setRepository(repository) {
156
+ // Validate format
157
+ if (!/^[a-z0-9-]+\/[a-z0-9-]+$/i.test(repository)) {
158
+ console.log(chalk.red('✗ Invalid repository format. Use: owner/repo'));
159
+ process.exit(1);
160
+ }
161
+
162
+ const spinner = ora('Validating repository...').start();
163
+
164
+ try {
165
+ // Try to fetch the index to validate
166
+ const response = await fetch(
167
+ `https://raw.githubusercontent.com/${repository}/main/index.json`
168
+ );
169
+
170
+ if (!response.ok && response.status !== 404) {
171
+ throw new Error(`Repository not accessible: ${response.status}`);
172
+ }
173
+
174
+ await updateConfig({ marketplaceRepo: repository });
175
+
176
+ spinner.succeed(chalk.green(`Repository set to: ${chalk.bold(repository)}`));
177
+ console.log('');
178
+ console.log(chalk.dim('Browse profiles with: ') + chalk.cyan('cpm list'));
179
+
180
+ } catch (error) {
181
+ spinner.fail(chalk.red(`Failed to set repository: ${error.message}`));
182
+ process.exit(1);
183
+ }
184
+ }
@@ -0,0 +1,83 @@
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
+ // Default paths
8
+ const DEFAULTS = {
9
+ claudeDir: join(HOME, '.claude'),
10
+ profilesDir: join(HOME, '.claude-profiles'),
11
+ cacheDir: join(HOME, '.claude-profiles', '.cache'),
12
+ configFile: join(HOME, '.claude-profiles', 'config.json'),
13
+ marketplaceRepo: 'YOUR_USERNAME/claude-profile-marketplace'
14
+ };
15
+
16
+ /**
17
+ * Ensure required directories exist
18
+ */
19
+ export function ensureDirs() {
20
+ const dirs = [DEFAULTS.profilesDir, DEFAULTS.cacheDir];
21
+ for (const dir of dirs) {
22
+ if (!existsSync(dir)) {
23
+ mkdirSync(dir, { recursive: true });
24
+ }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Get current configuration
30
+ */
31
+ export async function getConfig() {
32
+ ensureDirs();
33
+
34
+ let userConfig = {};
35
+
36
+ if (existsSync(DEFAULTS.configFile)) {
37
+ try {
38
+ userConfig = JSON.parse(readFileSync(DEFAULTS.configFile, 'utf-8'));
39
+ } catch (e) {
40
+ // Ignore invalid config
41
+ }
42
+ }
43
+
44
+ return {
45
+ ...DEFAULTS,
46
+ ...userConfig
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Update configuration
52
+ */
53
+ export async function updateConfig(updates) {
54
+ ensureDirs();
55
+
56
+ const current = await getConfig();
57
+ const newConfig = { ...current, ...updates };
58
+
59
+ // Only save user-configurable options
60
+ const toSave = {
61
+ marketplaceRepo: newConfig.marketplaceRepo
62
+ };
63
+
64
+ writeFileSync(DEFAULTS.configFile, JSON.stringify(toSave, null, 2));
65
+
66
+ return newConfig;
67
+ }
68
+
69
+ /**
70
+ * Get the path for a local profile
71
+ */
72
+ export function getProfilePath(name) {
73
+ return join(DEFAULTS.profilesDir, name);
74
+ }
75
+
76
+ /**
77
+ * Check if Claude directory exists
78
+ */
79
+ export function claudeDirExists() {
80
+ return existsSync(DEFAULTS.claudeDir);
81
+ }
82
+
83
+ export { DEFAULTS };
@@ -0,0 +1,280 @@
1
+ import { createWriteStream, createReadStream, existsSync, mkdirSync, readdirSync, statSync, rmSync, cpSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join, basename, relative } from 'path';
3
+ import archiver from 'archiver';
4
+ import extractZip from 'extract-zip';
5
+ import { getConfig, DEFAULTS } from './config.js';
6
+
7
+ // Files/patterns to exclude by default (secrets, caches, etc.)
8
+ const DEFAULT_EXCLUDES = [
9
+ '.credentials',
10
+ '.auth',
11
+ '*.key',
12
+ '*.pem',
13
+ '*.secret',
14
+ 'oauth_token*',
15
+ '.cache',
16
+ 'node_modules',
17
+ '.git'
18
+ ];
19
+
20
+ // Files that are safe to include
21
+ const SAFE_INCLUDES = [
22
+ 'settings.json',
23
+ 'settings.local.json',
24
+ 'CLAUDE.md',
25
+ 'commands',
26
+ 'commands/**',
27
+ 'projects',
28
+ 'projects/**',
29
+ 'templates',
30
+ 'templates/**',
31
+ 'hooks',
32
+ 'hooks/**',
33
+ 'mcp.json',
34
+ 'mcp_servers',
35
+ 'mcp_servers/**'
36
+ ];
37
+
38
+ /**
39
+ * Create a snapshot of the .claude folder
40
+ */
41
+ export async function createSnapshot(profileName, options = {}) {
42
+ const config = await getConfig();
43
+ const claudeDir = config.claudeDir;
44
+ const profileDir = join(config.profilesDir, profileName);
45
+
46
+ if (!existsSync(claudeDir)) {
47
+ throw new Error(`Claude directory not found: ${claudeDir}`);
48
+ }
49
+
50
+ // Create profile directory
51
+ if (existsSync(profileDir)) {
52
+ throw new Error(`Profile "${profileName}" already exists. Use a different name or delete the existing one.`);
53
+ }
54
+
55
+ mkdirSync(profileDir, { recursive: true });
56
+
57
+ const zipPath = join(profileDir, 'snapshot.zip');
58
+ const metadataPath = join(profileDir, 'profile.json');
59
+
60
+ // Create metadata
61
+ const metadata = {
62
+ name: profileName,
63
+ version: '1.0.0',
64
+ description: options.description || '',
65
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()) : [],
66
+ createdAt: new Date().toISOString(),
67
+ claudeVersion: await getClaudeVersion(),
68
+ platform: process.platform,
69
+ includesSecrets: options.includeSecrets || false,
70
+ files: []
71
+ };
72
+
73
+ // Create zip archive
74
+ const output = createWriteStream(zipPath);
75
+ const archive = archiver('zip', { zlib: { level: 9 } });
76
+
77
+ return new Promise((resolve, reject) => {
78
+ output.on('close', async () => {
79
+ // Save metadata
80
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
81
+ resolve({ profileDir, metadata });
82
+ });
83
+
84
+ archive.on('error', reject);
85
+ archive.on('entry', (entry) => {
86
+ metadata.files.push(entry.name);
87
+ });
88
+
89
+ archive.pipe(output);
90
+
91
+ // Add files from .claude directory
92
+ const files = getFilesToArchive(claudeDir, options.includeSecrets);
93
+
94
+ for (const file of files) {
95
+ const fullPath = join(claudeDir, file);
96
+ const stat = statSync(fullPath);
97
+
98
+ if (stat.isDirectory()) {
99
+ archive.directory(fullPath, file);
100
+ } else {
101
+ archive.file(fullPath, { name: file });
102
+ }
103
+ }
104
+
105
+ archive.finalize();
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Get list of files to archive (respecting excludes)
111
+ */
112
+ function getFilesToArchive(dir, includeSecrets = false) {
113
+ const files = [];
114
+ const excludes = includeSecrets ? [] : DEFAULT_EXCLUDES;
115
+
116
+ function walk(currentDir, relativePath = '') {
117
+ const entries = readdirSync(currentDir);
118
+
119
+ for (const entry of entries) {
120
+ const fullPath = join(currentDir, entry);
121
+ const relPath = relativePath ? join(relativePath, entry) : entry;
122
+
123
+ // Check exclusions
124
+ if (shouldExclude(entry, relPath, excludes)) {
125
+ continue;
126
+ }
127
+
128
+ const stat = statSync(fullPath);
129
+
130
+ if (stat.isDirectory()) {
131
+ walk(fullPath, relPath);
132
+ } else {
133
+ files.push(relPath);
134
+ }
135
+ }
136
+ }
137
+
138
+ walk(dir);
139
+ return files;
140
+ }
141
+
142
+ /**
143
+ * Check if a file/folder should be excluded
144
+ */
145
+ function shouldExclude(name, path, excludes) {
146
+ for (const pattern of excludes) {
147
+ if (pattern.startsWith('*.')) {
148
+ // Extension pattern
149
+ const ext = pattern.slice(1);
150
+ if (name.endsWith(ext)) return true;
151
+ } else if (name === pattern || path === pattern) {
152
+ return true;
153
+ } else if (pattern.endsWith('*') && name.startsWith(pattern.slice(0, -1))) {
154
+ return true;
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Extract a snapshot to the .claude folder
162
+ */
163
+ export async function extractSnapshot(profileName, options = {}) {
164
+ const config = await getConfig();
165
+ const profileDir = join(config.profilesDir, profileName);
166
+ const zipPath = join(profileDir, 'snapshot.zip');
167
+ const claudeDir = config.claudeDir;
168
+
169
+ if (!existsSync(zipPath)) {
170
+ throw new Error(`Profile "${profileName}" not found or corrupted`);
171
+ }
172
+
173
+ // Backup existing .claude if requested
174
+ if (options.backup && existsSync(claudeDir)) {
175
+ const backupName = `.claude-backup-${Date.now()}`;
176
+ const backupPath = join(DEFAULTS.profilesDir, backupName);
177
+ cpSync(claudeDir, backupPath, { recursive: true });
178
+ }
179
+
180
+ // Clear existing .claude directory (if force or confirmed)
181
+ if (existsSync(claudeDir)) {
182
+ if (!options.force) {
183
+ throw new Error('Claude directory exists. Use --force to overwrite or --backup to save current config.');
184
+ }
185
+ rmSync(claudeDir, { recursive: true, force: true });
186
+ }
187
+
188
+ // Create fresh .claude directory
189
+ mkdirSync(claudeDir, { recursive: true });
190
+
191
+ // Extract snapshot
192
+ await extractZip(zipPath, { dir: claudeDir });
193
+
194
+ return { claudeDir };
195
+ }
196
+
197
+ /**
198
+ * Extract a downloaded snapshot (from marketplace)
199
+ */
200
+ export async function extractDownloadedSnapshot(zipBuffer, options = {}) {
201
+ const config = await getConfig();
202
+ const claudeDir = config.claudeDir;
203
+ const tempZip = join(config.cacheDir, `temp-${Date.now()}.zip`);
204
+
205
+ // Write buffer to temp file
206
+ writeFileSync(tempZip, zipBuffer);
207
+
208
+ try {
209
+ // Backup existing .claude if requested
210
+ if (options.backup && existsSync(claudeDir)) {
211
+ const backupName = `.claude-backup-${Date.now()}`;
212
+ const backupPath = join(DEFAULTS.profilesDir, backupName);
213
+ cpSync(claudeDir, backupPath, { recursive: true });
214
+ }
215
+
216
+ // Clear existing .claude directory
217
+ if (existsSync(claudeDir)) {
218
+ if (!options.force) {
219
+ throw new Error('Claude directory exists. Use --force to overwrite or --backup to save current config.');
220
+ }
221
+ rmSync(claudeDir, { recursive: true, force: true });
222
+ }
223
+
224
+ mkdirSync(claudeDir, { recursive: true });
225
+ await extractZip(tempZip, { dir: claudeDir });
226
+
227
+ return { claudeDir };
228
+ } finally {
229
+ // Clean up temp file
230
+ if (existsSync(tempZip)) {
231
+ rmSync(tempZip);
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Get Claude CLI version if installed
238
+ */
239
+ async function getClaudeVersion() {
240
+ try {
241
+ const { execSync } = await import('child_process');
242
+ const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
243
+ return version;
244
+ } catch {
245
+ return 'unknown';
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Read profile metadata
251
+ */
252
+ export function readProfileMetadata(profileName) {
253
+ const config = DEFAULTS;
254
+ const metadataPath = join(config.profilesDir, profileName, 'profile.json');
255
+
256
+ if (!existsSync(metadataPath)) {
257
+ return null;
258
+ }
259
+
260
+ return JSON.parse(readFileSync(metadataPath, 'utf-8'));
261
+ }
262
+
263
+ /**
264
+ * List all local profiles
265
+ */
266
+ export function listLocalProfileNames() {
267
+ const profilesDir = DEFAULTS.profilesDir;
268
+
269
+ if (!existsSync(profilesDir)) {
270
+ return [];
271
+ }
272
+
273
+ return readdirSync(profilesDir)
274
+ .filter(name => {
275
+ if (name.startsWith('.')) return false;
276
+ const profilePath = join(profilesDir, name);
277
+ const stat = statSync(profilePath);
278
+ return stat.isDirectory() && existsSync(join(profilePath, 'profile.json'));
279
+ });
280
+ }