ppcos 0.3.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,229 @@
1
+ /**
2
+ * init command - Create a new client workspace
3
+ *
4
+ * Usage: ppcos init <client-name> [--skip-config]
5
+ */
6
+
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import { readFile, writeFile } from 'node:fs/promises';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { validateClientName } from '../utils/validation.js';
12
+ import { calculateChecksum } from '../utils/checksum.js';
13
+ import { createManifest, writeManifest, manifestExists, getManagedType } from '../utils/manifest.js';
14
+ import { getAllFiles, copyFileWithDirs, ensureDir, fileExists } from '../utils/fs-helpers.js';
15
+ import { fetchSkills } from '../utils/skills-fetcher.js';
16
+ import { requireAuth } from '../utils/auth.js';
17
+ import logger from '../utils/logger.js';
18
+ import ora from 'ora';
19
+
20
+ // Get package root directory
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const PACKAGE_ROOT = join(__dirname, '..', '..');
24
+
25
+ /**
26
+ * Get package version from package.json
27
+ * @returns {string}
28
+ */
29
+ function getPackageVersion() {
30
+ const pkgPath = join(PACKAGE_ROOT, 'package.json');
31
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
32
+ return pkg.version;
33
+ }
34
+
35
+ /**
36
+ * Get path to .claude-base template directory
37
+ * @returns {string}
38
+ */
39
+ function getBaseTemplatePath() {
40
+ return join(PACKAGE_ROOT, '.claude-base');
41
+ }
42
+
43
+ /**
44
+ * Get clients directory path
45
+ * @returns {string}
46
+ */
47
+ function getClientsDir() {
48
+ return join(process.cwd(), 'clients');
49
+ }
50
+
51
+ /**
52
+ * Add client to main-config.json
53
+ * @param {string} clientName
54
+ */
55
+ async function addToMainConfig(clientName) {
56
+ const configPath = join(process.cwd(), 'main-config.json');
57
+
58
+ let config;
59
+ if (existsSync(configPath)) {
60
+ const content = await readFile(configPath, 'utf8');
61
+ config = JSON.parse(content);
62
+
63
+ // Check if client already exists
64
+ const exists = config.clients.some(c => c.name === clientName);
65
+ if (exists) {
66
+ logger.debug(`Client "${clientName}" already in main-config.json`);
67
+ return;
68
+ }
69
+ } else {
70
+ // Create new config
71
+ config = {
72
+ version: '1.0',
73
+ clients: []
74
+ };
75
+ }
76
+
77
+ // Add the new client
78
+ config.clients.push({
79
+ name: clientName,
80
+ enabled: true
81
+ });
82
+
83
+ await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
84
+ logger.debug(`Added "${clientName}" to main-config.json`);
85
+ }
86
+
87
+ /**
88
+ * Create main-config.json template
89
+ */
90
+ async function createConfigTemplate() {
91
+ const configPath = join(process.cwd(), 'main-config.json');
92
+
93
+ if (existsSync(configPath)) {
94
+ logger.error('main-config.json already exists.');
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+
99
+ const template = {
100
+ version: '1.0',
101
+ clients: [
102
+ { name: 'client-example', enabled: true }
103
+ ]
104
+ };
105
+
106
+ await writeFile(configPath, JSON.stringify(template, null, 2) + '\n');
107
+
108
+ logger.success('Created main-config.json');
109
+ console.log('');
110
+ console.log('Next steps:');
111
+ console.log(' 1. Edit main-config.json with your client names');
112
+ console.log(' 2. Run: ppcos init-all');
113
+ }
114
+
115
+ /**
116
+ * Main init command handler
117
+ * @param {string} [clientName] - Name of the client (optional)
118
+ * @param {object} options - Command options
119
+ * @param {boolean} options.skipConfig - Skip adding to main-config.json
120
+ */
121
+ export default async function init(clientName, options = {}) {
122
+ // No client name = create config template
123
+ if (!clientName) {
124
+ await createConfigTemplate();
125
+ return;
126
+ }
127
+
128
+ // 1. Validate client name
129
+ const validation = validateClientName(clientName);
130
+ if (!validation.valid) {
131
+ logger.error(`Invalid client name "${clientName}". ${validation.error}`);
132
+ logger.info('Use lowercase letters, numbers, and hyphens only. Must start with a letter.');
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ // 2. Check for existing folder
138
+ const clientsDir = getClientsDir();
139
+ const clientDir = join(clientsDir, clientName);
140
+
141
+ if (existsSync(clientDir)) {
142
+ if (manifestExists(clientDir)) {
143
+ logger.error(`Folder "clients/${clientName}" already exists with a managed workspace.`);
144
+ logger.info("Use 'ppcos update' to update existing clients.");
145
+ } else {
146
+ logger.error(`Folder "clients/${clientName}" already exists.`);
147
+ logger.info('Remove it manually or choose a different name.');
148
+ }
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+
153
+ // 3. Fetch skills from API (with local fallback for dev/testing)
154
+ const spinner = ora('Fetching skills from API...').start();
155
+ let usedLocalFallback = false;
156
+
157
+ try {
158
+ await fetchSkills(clientDir);
159
+ spinner.succeed('Skills downloaded');
160
+ } catch (error) {
161
+ spinner.fail('Failed to fetch skills');
162
+
163
+ // Fallback to local .claude-base for development/testing
164
+ const basePath = getBaseTemplatePath();
165
+ if (existsSync(basePath)) {
166
+ logger.warn('Using local .claude-base (dev/testing mode)');
167
+ const baseFiles = await getAllFiles(basePath);
168
+
169
+ for (const relativePath of baseFiles) {
170
+ const srcPath = join(basePath, relativePath);
171
+ const destPath = join(clientDir, relativePath);
172
+ await copyFileWithDirs(srcPath, destPath);
173
+ }
174
+ usedLocalFallback = true;
175
+ } else {
176
+ logger.error(error.message);
177
+ logger.error('No local fallback available. Run: ppcos login');
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+ }
182
+
183
+ // 4. Calculate checksums for all downloaded files
184
+ const version = getPackageVersion();
185
+ const baseFiles = await getAllFiles(clientDir);
186
+ const managedFiles = {};
187
+
188
+ const checksumSpinner = ora(`Calculating checksums for ${baseFiles.length} files...`).start();
189
+
190
+ for (const relativePath of baseFiles) {
191
+ const filePath = join(clientDir, relativePath);
192
+ const checksum = await calculateChecksum(filePath);
193
+ const managedType = getManagedType(relativePath);
194
+ managedFiles[relativePath] = {
195
+ checksum,
196
+ version,
197
+ managedType
198
+ };
199
+ }
200
+
201
+ checksumSpinner.succeed('Checksums calculated');
202
+
203
+ // 5. Generate and write .managed.json
204
+ const manifest = createManifest(version, managedFiles);
205
+ await writeManifest(clientDir, manifest);
206
+
207
+ // 6. Update main-config.json (unless --skip-config)
208
+ if (!options.skipConfig) {
209
+ try {
210
+ await addToMainConfig(clientName);
211
+ } catch (err) {
212
+ logger.warn(`Could not update main-config.json: ${err.message}`);
213
+ }
214
+ }
215
+
216
+ // 7. Display success message
217
+ console.log('');
218
+ logger.success(`Client "${clientName}" initialized (v${version})`);
219
+ console.log('');
220
+ console.log(`Location: clients/${clientName}/`);
221
+ console.log('');
222
+ console.log('Next steps:');
223
+ console.log(` 1. cd clients/${clientName}`);
224
+ console.log(' 2. Customize CLAUDE.md for this client');
225
+ console.log(' 3. Run /ppcos <website> to gather brand context');
226
+ }
227
+
228
+ // Export helpers for testing
229
+ export { getPackageVersion, getBaseTemplatePath, getClientsDir, addToMainConfig };
@@ -0,0 +1,85 @@
1
+ import readline from 'readline';
2
+ import logger from '../utils/logger.js';
3
+ import { writeAuth } from '../utils/auth.js';
4
+ import { sendVerificationCode, verifyCode } from '../utils/api-client.js';
5
+ import ora from 'ora';
6
+
7
+ /**
8
+ * Interactive login flow with email + OTP
9
+ */
10
+ export async function login() {
11
+ const rl = readline.createInterface({
12
+ input: process.stdin,
13
+ output: process.stdout
14
+ });
15
+
16
+ try {
17
+ // Step 1: Get email
18
+ const email = await new Promise((resolve) => {
19
+ rl.question('Email: ', resolve);
20
+ });
21
+
22
+ if (!email || !email.includes('@')) {
23
+ logger.error('Invalid email address');
24
+ rl.close();
25
+ return;
26
+ }
27
+
28
+ // Step 2: Send code
29
+ // Close readline before spinner to avoid terminal conflicts
30
+ rl.close();
31
+
32
+ const spinner = ora('Sending verification code...').start();
33
+
34
+ let sendResult;
35
+ try {
36
+ sendResult = await sendVerificationCode(email.trim());
37
+ spinner.succeed('Code sent to your email');
38
+ } catch (error) {
39
+ spinner.fail('Failed to send code');
40
+ logger.error(error.message);
41
+ return;
42
+ }
43
+
44
+ // Step 3: Get code
45
+ // Recreate readline after spinner
46
+ const rl2 = readline.createInterface({
47
+ input: process.stdin,
48
+ output: process.stdout
49
+ });
50
+
51
+ const code = await new Promise((resolve) => {
52
+ rl2.question('Enter verification code: ', resolve);
53
+ });
54
+ rl2.close();
55
+
56
+ if (!code || code.length !== 6) {
57
+ logger.error('Invalid code format');
58
+ return;
59
+ }
60
+
61
+ // Step 4: Verify code
62
+ const verifySpinner = ora('Verifying...').start();
63
+
64
+ try {
65
+ const result = await verifyCode(email.trim(), code.trim(), sendResult.token);
66
+ verifySpinner.succeed('Verified');
67
+
68
+ // Step 5: Store session
69
+ writeAuth({
70
+ email: result.email,
71
+ sessionToken: result.sessionToken,
72
+ expiresAt: result.expiresAt
73
+ });
74
+
75
+ logger.success(`✓ Logged in as ${result.email}`);
76
+ logger.info(`Session expires: ${new Date(result.expiresAt).toLocaleString()}`);
77
+ } catch (error) {
78
+ verifySpinner.fail('Verification failed');
79
+ logger.error(error.message);
80
+ }
81
+ } catch (error) {
82
+ logger.error(`Login failed: ${error.message}`);
83
+ rl.close();
84
+ }
85
+ }
@@ -0,0 +1,17 @@
1
+ import logger from '../utils/logger.js';
2
+ import { clearAuth, readAuth } from '../utils/auth.js';
3
+
4
+ /**
5
+ * Clear authentication session
6
+ */
7
+ export async function logout() {
8
+ const auth = readAuth();
9
+
10
+ if (!auth) {
11
+ logger.info('Not logged in');
12
+ return;
13
+ }
14
+
15
+ clearAuth();
16
+ logger.success(`✓ Logged out ${auth.email}`);
17
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * status command - Show version and modification status
3
+ *
4
+ * Usage: ppcos status [--client <name>]
5
+ */
6
+
7
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import {
11
+ readManifest,
12
+ manifestExists,
13
+ detectModifications
14
+ } from '../utils/manifest.js';
15
+ import { getAllFiles } from '../utils/fs-helpers.js';
16
+ import { readAuth } from '../utils/auth.js';
17
+ import logger from '../utils/logger.js';
18
+ import chalk from 'chalk';
19
+
20
+ // Get package root directory
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const PACKAGE_ROOT = join(__dirname, '..', '..');
24
+
25
+ /**
26
+ * Get package version from package.json
27
+ * @returns {string}
28
+ */
29
+ function getPackageVersion() {
30
+ const pkgPath = join(PACKAGE_ROOT, 'package.json');
31
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
32
+ return pkg.version;
33
+ }
34
+
35
+ /**
36
+ * Get path to .claude-base template directory
37
+ * @returns {string}
38
+ */
39
+ function getBaseTemplatePath() {
40
+ return join(PACKAGE_ROOT, '.claude-base');
41
+ }
42
+
43
+ /**
44
+ * Get clients directory path
45
+ * @returns {string}
46
+ */
47
+ function getClientsDir() {
48
+ return join(process.cwd(), 'clients');
49
+ }
50
+
51
+ /**
52
+ * Find all client directories with .managed.json
53
+ * @returns {string[]} Array of client names
54
+ */
55
+ function discoverClients() {
56
+ const clientsDir = getClientsDir();
57
+
58
+ if (!existsSync(clientsDir)) {
59
+ return [];
60
+ }
61
+
62
+ const entries = readdirSync(clientsDir, { withFileTypes: true });
63
+ const clients = [];
64
+
65
+ for (const entry of entries) {
66
+ if (entry.isDirectory()) {
67
+ const clientDir = join(clientsDir, entry.name);
68
+ if (manifestExists(clientDir)) {
69
+ clients.push(entry.name);
70
+ }
71
+ }
72
+ }
73
+
74
+ return clients;
75
+ }
76
+
77
+ /**
78
+ * Find custom skills in a client directory
79
+ * Custom skills are skill directories not tracked in managedFiles
80
+ * @param {string} clientDir - Path to client directory
81
+ * @param {object} manifest - The manifest object
82
+ * @returns {string[]} Array of custom skill names
83
+ */
84
+ function findCustomSkills(clientDir, manifest) {
85
+ const skillsDir = join(clientDir, '.claude', 'skills');
86
+
87
+ if (!existsSync(skillsDir)) {
88
+ return [];
89
+ }
90
+
91
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
92
+ const customSkills = [];
93
+
94
+ // Get managed skill directories from manifest
95
+ const managedSkillDirs = new Set();
96
+ for (const path of Object.keys(manifest.managedFiles)) {
97
+ if (path.startsWith('.claude/skills/')) {
98
+ const parts = path.split('/');
99
+ if (parts.length >= 3) {
100
+ managedSkillDirs.add(parts[2]);
101
+ }
102
+ }
103
+ }
104
+
105
+ for (const entry of entries) {
106
+ if (entry.isDirectory() && !managedSkillDirs.has(entry.name)) {
107
+ customSkills.push(entry.name);
108
+ }
109
+ }
110
+
111
+ return customSkills;
112
+ }
113
+
114
+ /**
115
+ * Get status for a single client
116
+ * @param {string} clientName - Client name
117
+ * @returns {Promise<object>} Client status object
118
+ */
119
+ async function getClientStatus(clientName) {
120
+ const clientsDir = getClientsDir();
121
+ const clientDir = join(clientsDir, clientName);
122
+ const basePath = getBaseTemplatePath();
123
+ const packageVersion = getPackageVersion();
124
+
125
+ // Read manifest
126
+ const manifest = await readManifest(clientDir);
127
+ const currentVersion = manifest.baseVersion;
128
+
129
+ // Get base template files for detecting modifications
130
+ const baseFiles = await getAllFiles(basePath);
131
+
132
+ // Detect modifications
133
+ const mods = await detectModifications(clientDir, manifest, baseFiles);
134
+
135
+ // Find custom skills
136
+ const customSkills = findCustomSkills(clientDir, manifest);
137
+
138
+ // Find config files
139
+ const configFiles = Object.entries(manifest.managedFiles)
140
+ .filter(([path, info]) => (info.managedType || 'update') === 'config')
141
+ .map(([path]) => path);
142
+
143
+ // Check if update available
144
+ const updateAvailable = currentVersion !== packageVersion;
145
+
146
+ return {
147
+ name: clientName,
148
+ version: currentVersion,
149
+ packageVersion,
150
+ updateAvailable,
151
+ managedCount: Object.keys(manifest.managedFiles).length,
152
+ modifiedFiles: mods.modified,
153
+ missingFiles: mods.missing,
154
+ customSkills,
155
+ conflicts: manifest.conflicts || [],
156
+ configFiles
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Main status command handler
162
+ * @param {object} options - Command options
163
+ * @param {string} [options.client] - Show only this client
164
+ */
165
+ export default async function status(options = {}) {
166
+ const packageVersion = getPackageVersion();
167
+ const basePath = getBaseTemplatePath();
168
+
169
+ // Check base template exists
170
+ if (!existsSync(basePath)) {
171
+ logger.error('Template directory not found. Package may be corrupted.');
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+
176
+ // Discover clients
177
+ let clients = discoverClients();
178
+
179
+ if (clients.length === 0) {
180
+ logger.info('No clients found.');
181
+ console.log('');
182
+ console.log('To create a client:');
183
+ console.log(' ppcos init <client-name>');
184
+ console.log('');
185
+ console.log('Or create main-config.json and run:');
186
+ console.log(' ppcos init-all');
187
+ return;
188
+ }
189
+
190
+ // Filter to specific client if requested
191
+ if (options.client) {
192
+ if (!clients.includes(options.client)) {
193
+ logger.error(`Client "${options.client}" not found.`);
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ clients = [options.client];
198
+ }
199
+
200
+ // Print header
201
+ console.log('ppcos-hub/');
202
+ console.log('');
203
+
204
+ // Process each client
205
+ for (const clientName of clients) {
206
+ try {
207
+ const clientStatus = await getClientStatus(clientName);
208
+
209
+ // Client name
210
+ console.log(` ${clientStatus.name}`);
211
+
212
+ // Version line
213
+ if (clientStatus.updateAvailable) {
214
+ console.log(` Version: ${clientStatus.version} → ${clientStatus.packageVersion} available`);
215
+ } else {
216
+ console.log(` Version: ${clientStatus.version} (up to date)`);
217
+ }
218
+
219
+ // Managed files count
220
+ console.log(` Managed: ${clientStatus.managedCount} files`);
221
+
222
+ // Modified files
223
+ console.log(` Modified: ${clientStatus.modifiedFiles.length} files`);
224
+ for (const file of clientStatus.modifiedFiles) {
225
+ console.log(` - ${file}`);
226
+ }
227
+
228
+ // Custom skills
229
+ const skillCount = clientStatus.customSkills.length;
230
+ if (skillCount === 0) {
231
+ console.log(' Custom: 0 skills');
232
+ } else if (skillCount === 1) {
233
+ console.log(` Custom: 1 skill (${clientStatus.customSkills[0]})`);
234
+ } else {
235
+ console.log(` Custom: ${skillCount} skills (${clientStatus.customSkills.join(', ')})`);
236
+ }
237
+
238
+ // Config files
239
+ const configCount = clientStatus.configFiles?.length || 0;
240
+ if (configCount > 0) {
241
+ console.log(` Config: ${configCount} files (never updated)`);
242
+ for (const file of clientStatus.configFiles) {
243
+ console.log(` - ${file}`);
244
+ }
245
+ }
246
+
247
+ // Conflicts
248
+ console.log(` Conflicts: ${clientStatus.conflicts.length}`);
249
+
250
+ console.log('');
251
+ } catch (err) {
252
+ logger.error(`Failed to get status for ${clientName}: ${err.message}`);
253
+ console.log('');
254
+ }
255
+ }
256
+
257
+ // Auth status
258
+ const auth = readAuth();
259
+ if (auth?.sessionToken) {
260
+ const expiresAt = new Date(auth.expiresAt);
261
+ const now = new Date();
262
+ const isExpired = expiresAt < now;
263
+
264
+ if (isExpired) {
265
+ console.log(`Auth: ${chalk.red('expired')} (${auth.email})`);
266
+ console.log(` Run: ${chalk.cyan('ppcos login')}`);
267
+ } else {
268
+ const hoursLeft = Math.floor((expiresAt - now) / (1000 * 60 * 60));
269
+ console.log(`Auth: ${chalk.green('active')} (${auth.email})`);
270
+ console.log(` Expires in ${hoursLeft}h`);
271
+ }
272
+ } else {
273
+ console.log(`Auth: ${chalk.red('not logged in')} - run ${chalk.cyan('ppcos login')}`);
274
+ }
275
+
276
+ // Package version footer
277
+ console.log('');
278
+ console.log(`Package version: ${packageVersion}`);
279
+ }
280
+
281
+ // Export helpers for testing
282
+ export {
283
+ getPackageVersion,
284
+ getBaseTemplatePath,
285
+ getClientsDir,
286
+ discoverClients,
287
+ findCustomSkills,
288
+ getClientStatus
289
+ };