ppcos 1.2.0 → 1.2.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.
@@ -4,18 +4,24 @@
4
4
  * Usage: ppcos init-all [--force]
5
5
  */
6
6
 
7
- import { existsSync } from 'node:fs';
7
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
8
8
  import { readFile, mkdir, rename } from 'node:fs/promises';
9
9
  import { join, dirname } from 'node:path';
10
+ import { tmpdir } from 'node:os';
10
11
  import { validateConfig } from '../utils/validation.js';
11
12
  import { calculateChecksum } from '../utils/checksum.js';
12
13
  import { createManifest, writeManifest, manifestExists, getManagedType, hubManifestExists } from '../utils/manifest.js';
13
- import { getAllFiles } from '../utils/fs-helpers.js';
14
+ import { getAllFiles, copyFileWithDirs } from '../utils/fs-helpers.js';
14
15
  import { fetchSkills } from '../utils/skills-fetcher.js';
15
16
  import { installHubSkills } from './init.js';
16
17
  import logger from '../utils/logger.js';
18
+ import ora from 'ora';
17
19
  import { getPackageVersion, getPackageRoot, getClientsDir } from '../utils/paths.js';
18
20
 
21
+ // Top-level zip entries to skip when materializing a client folder.
22
+ // hub-base/ is for hub-level skills; tmp/ holds runtime artifacts.
23
+ const CLIENT_COPY_EXCLUDE_DIRS = new Set(['tmp', 'node_modules', 'hub-base']);
24
+
19
25
  /**
20
26
  * Get config file path
21
27
  * @returns {string}
@@ -77,13 +83,14 @@ async function backupClient(clientDir) {
77
83
  }
78
84
 
79
85
  /**
80
- * Initialize a single client
86
+ * Initialize a single client by copying from a pre-extracted base template.
81
87
  * @param {string} clientName
82
88
  * @param {string} clientsDir
89
+ * @param {string} basePath - Directory holding the extracted skills zip
83
90
  * @param {object} options
84
91
  * @returns {Promise<{ status: 'initialized' | 'skipped' | 'failed', message?: string }>}
85
92
  */
86
- async function initializeClient(clientName, clientsDir, options = {}) {
93
+ async function initializeClient(clientName, clientsDir, basePath, options = {}) {
87
94
  const clientDir = join(clientsDir, clientName);
88
95
  const version = getPackageVersion();
89
96
 
@@ -102,8 +109,15 @@ async function initializeClient(clientName, clientsDir, options = {}) {
102
109
  }
103
110
 
104
111
  try {
105
- // Fetch skills from API
106
- await fetchSkills(clientDir);
112
+ // Copy template files from the shared base to this client, skipping
113
+ // hub-base/ (hub-level skills, installed separately at the hub root).
114
+ const baseFiles = await getAllFiles(basePath, basePath, CLIENT_COPY_EXCLUDE_DIRS);
115
+
116
+ for (const relativePath of baseFiles) {
117
+ const srcPath = join(basePath, relativePath);
118
+ const destPath = join(clientDir, relativePath);
119
+ await copyFileWithDirs(srcPath, destPath);
120
+ }
107
121
 
108
122
  // Calculate checksums for all files in client dir
109
123
  const allFiles = await getAllFiles(clientDir);
@@ -168,46 +182,69 @@ export default async function initAll(options = {}) {
168
182
  console.log(`Found ${enabledClients.length} client${enabledClients.length === 1 ? '' : 's'} in main-config.json`);
169
183
  console.log('');
170
184
 
171
- // 2. Process each client
172
- const results = {
173
- initialized: 0,
174
- skipped: 0,
175
- failed: 0
176
- };
185
+ // 2. Download skills once into a shared temp dir, then copy locally
186
+ // per client. One API call serves N clients, avoiding rate limiting
187
+ // (API caps GET /api/skills at 10/hr per session).
188
+ const tempDir = mkdtempSync(join(tmpdir(), 'ppcos-init-all-'));
189
+ const spinner = ora('Downloading skills from API...').start();
190
+ try {
191
+ await fetchSkills(tempDir);
192
+ spinner.succeed('Skills downloaded');
193
+ } catch (error) {
194
+ spinner.fail('Failed to download skills');
195
+ logger.error(error.message);
196
+ if (error.message.includes('Not authenticated') || error.message.includes('401') || error.message.includes('expired')) {
197
+ logger.info('Try: ppcos login');
198
+ }
199
+ rmSync(tempDir, { recursive: true, force: true });
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+
204
+ try {
205
+ // 3. Process each client
206
+ const results = {
207
+ initialized: 0,
208
+ skipped: 0,
209
+ failed: 0
210
+ };
177
211
 
178
- for (const client of enabledClients) {
179
- process.stdout.write(`Initializing ${client.name}... `);
212
+ for (const client of enabledClients) {
213
+ process.stdout.write(`Initializing ${client.name}... `);
180
214
 
181
- const initResult = await initializeClient(client.name, clientsDir, options);
215
+ const initResult = await initializeClient(client.name, clientsDir, tempDir, options);
182
216
 
183
- if (initResult.status === 'initialized') {
184
- logger.success('');
185
- results.initialized++;
186
- } else if (initResult.status === 'skipped') {
187
- console.log(`Skipped (${initResult.message})`);
188
- results.skipped++;
189
- } else {
190
- logger.error(initResult.message || 'Unknown error');
191
- results.failed++;
217
+ if (initResult.status === 'initialized') {
218
+ logger.success('');
219
+ results.initialized++;
220
+ } else if (initResult.status === 'skipped') {
221
+ console.log(`Skipped (${initResult.message})`);
222
+ results.skipped++;
223
+ } else {
224
+ logger.error(initResult.message || 'Unknown error');
225
+ results.failed++;
226
+ }
192
227
  }
193
- }
194
228
 
195
- // 3. Install hub skills if not already installed
196
- const hubDir = process.cwd();
197
- if (!hubManifestExists(hubDir)) {
198
- const hubBasePath = join(getPackageRoot(), 'hub-base');
199
- await installHubSkills(hubBasePath);
200
- }
229
+ // 4. Install hub skills if not already installed
230
+ const hubDir = process.cwd();
231
+ if (!hubManifestExists(hubDir)) {
232
+ const hubBasePath = join(getPackageRoot(), 'hub-base');
233
+ await installHubSkills(hubBasePath);
234
+ }
201
235
 
202
- // 4. Display summary
203
- console.log('');
204
- console.log('Summary:');
205
- console.log(` Initialized: ${results.initialized}`);
206
- console.log(` Skipped: ${results.skipped}`);
207
- console.log(` Failed: ${results.failed}`);
236
+ // 5. Display summary
237
+ console.log('');
238
+ console.log('Summary:');
239
+ console.log(` Initialized: ${results.initialized}`);
240
+ console.log(` Skipped: ${results.skipped}`);
241
+ console.log(` Failed: ${results.failed}`);
208
242
 
209
- if (results.failed > 0) {
210
- process.exitCode = 1;
243
+ if (results.failed > 0) {
244
+ process.exitCode = 1;
245
+ }
246
+ } finally {
247
+ rmSync(tempDir, { recursive: true, force: true });
211
248
  }
212
249
  }
213
250
 
@@ -193,7 +193,7 @@ export default async function init(clientName, options = {}) {
193
193
  // 3. Fetch skills from API (with local fallback for dev/testing)
194
194
  const spinner = ora('Fetching skills from API...').start();
195
195
  try {
196
- await fetchSkills(clientDir);
196
+ await fetchSkills(clientDir, { excludeTopLevel: ['hub-base'] });
197
197
  spinner.succeed('Skills downloaded');
198
198
  } catch (error) {
199
199
  spinner.fail('Failed to fetch skills');
@@ -4,7 +4,7 @@
4
4
  * Usage: ppcos update [--client <name>] [--dry-run]
5
5
  */
6
6
 
7
- import { existsSync, unlinkSync } from 'node:fs';
7
+ import { existsSync, unlinkSync, statSync, chmodSync } from 'node:fs';
8
8
  import { join, sep } from 'node:path';
9
9
  import {
10
10
  readManifest,
@@ -217,6 +217,27 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
217
217
  }
218
218
  }
219
219
 
220
+ // Heal shell-hook exec bits across the full managed tree.
221
+ // Content-unchanged hooks are skipped by the checksum-driven copy loop above,
222
+ // so clients that updated before the skills-fetcher chmod fix kept 0644 hooks
223
+ // and Claude Code's stop hook fails with "Permission denied". This restores
224
+ // +x on any managed *.sh missing it. Idempotent; no-op on Windows.
225
+ if (process.platform !== 'win32') {
226
+ for (const relativePath of Object.keys(manifest.managedFiles)) {
227
+ if (!relativePath.endsWith('.sh')) continue;
228
+ const fullPath = join(clientDir, relativePath);
229
+ if (!existsSync(fullPath)) continue;
230
+ try {
231
+ const mode = statSync(fullPath).mode;
232
+ if ((mode & 0o111) !== 0o111) {
233
+ chmodSync(fullPath, mode | 0o755);
234
+ }
235
+ } catch {
236
+ // Don't fail update over a chmod hiccup
237
+ }
238
+ }
239
+ }
240
+
220
241
  // Update manifest
221
242
  manifest.baseVersion = packageVersion;
222
243
  manifest.lastUpdated = new Date().toISOString();
@@ -1,16 +1,22 @@
1
- import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
1
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
2
2
  import { Open } from 'unzipper';
3
- import { join } from 'path';
3
+ import { join, dirname } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { downloadSkills } from './api-client.js';
6
6
  import { readAuth } from './auth.js';
7
+ import { getAllFiles } from './fs-helpers.js';
7
8
  import logger from './logger.js';
8
9
 
9
10
  /**
10
11
  * Fetch skills from API and extract to target directory
11
12
  * @param {string} targetDir - Directory to extract skills into
13
+ * @param {object} [options]
14
+ * @param {string[]} [options.excludeTopLevel] - Top-level zip entry names to skip
15
+ * (e.g. ['hub-base'] when extracting into a client folder — hub skills are
16
+ * handled separately via update-hub.js).
12
17
  */
13
- export async function fetchSkills(targetDir) {
18
+ export async function fetchSkills(targetDir, options = {}) {
19
+ const { excludeTopLevel } = options;
14
20
  const auth = readAuth();
15
21
 
16
22
  if (!auth?.sessionToken) {
@@ -35,7 +41,36 @@ export async function fetchSkills(targetDir) {
35
41
  writeFileSync(tempZip, buffer);
36
42
 
37
43
  const directory = await Open.file(tempZip);
38
- await directory.extract({ path: targetDir });
44
+
45
+ if (!excludeTopLevel || excludeTopLevel.length === 0) {
46
+ await directory.extract({ path: targetDir });
47
+ } else {
48
+ const excludeSet = new Set(excludeTopLevel);
49
+ for (const entry of directory.files) {
50
+ const top = entry.path.split('/')[0];
51
+ if (excludeSet.has(top)) {
52
+ continue;
53
+ }
54
+ const destPath = join(targetDir, entry.path);
55
+ if (entry.type === 'Directory') {
56
+ if (!existsSync(destPath)) mkdirSync(destPath, { recursive: true });
57
+ } else {
58
+ const parent = dirname(destPath);
59
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
60
+ const content = await entry.buffer();
61
+ writeFileSync(destPath, content);
62
+ }
63
+ }
64
+ }
65
+
66
+ // unzipper's extract() drops unix mode bits — restore +x on shell hooks
67
+ // so Claude Code can run them. No-op on Windows (chmod ignores mode bits).
68
+ const extracted = await getAllFiles(targetDir);
69
+ for (const rel of extracted) {
70
+ if (rel.endsWith('.sh')) {
71
+ try { chmodSync(join(targetDir, rel), 0o755); } catch {}
72
+ }
73
+ }
39
74
  } finally {
40
75
  try { unlinkSync(tempZip); } catch {}
41
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ppcos",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "CLI tool to manage Google Ads AI workflow skills and agents for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {