ppcos 1.2.0 → 1.2.2

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,17 +4,23 @@
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';
17
- import { getPackageVersion, getPackageRoot, getClientsDir } from '../utils/paths.js';
18
+ import ora from 'ora';
19
+ import { getPackageVersion, getClientsDir } from '../utils/paths.js';
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']);
18
24
 
19
25
  /**
20
26
  * Get config file path
@@ -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,72 @@ 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
+ // Use the hub-base/ extracted from the API zip — the published npm
231
+ // package only ships bin/ + lib/, so getPackageRoot()/hub-base doesn't
232
+ // exist for normal installs.
233
+ const hubDir = process.cwd();
234
+ if (!hubManifestExists(hubDir)) {
235
+ const hubBasePath = join(tempDir, 'hub-base');
236
+ await installHubSkills(hubBasePath);
237
+ }
201
238
 
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}`);
239
+ // 5. Display summary
240
+ console.log('');
241
+ console.log('Summary:');
242
+ console.log(` Initialized: ${results.initialized}`);
243
+ console.log(` Skipped: ${results.skipped}`);
244
+ console.log(` Failed: ${results.failed}`);
208
245
 
209
- if (results.failed > 0) {
210
- process.exitCode = 1;
246
+ if (results.failed > 0) {
247
+ process.exitCode = 1;
248
+ }
249
+ } finally {
250
+ rmSync(tempDir, { recursive: true, force: true });
211
251
  }
212
252
  }
213
253
 
@@ -4,9 +4,10 @@
4
4
  * Usage: ppcos init <client-name> [--skip-config]
5
5
  */
6
6
 
7
- import { existsSync, rmSync } from 'node:fs';
7
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
8
8
  import { readFile, writeFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
10
11
  import { validateClientName } from '../utils/validation.js';
11
12
  import { calculateChecksum } from '../utils/checksum.js';
12
13
  import { createManifest, writeManifest, manifestExists, getManagedType, hubManifestExists, writeHubManifest } from '../utils/manifest.js';
@@ -142,9 +143,42 @@ async function createConfigTemplate() {
142
143
 
143
144
  logger.success('Created main-config.json');
144
145
 
145
- // Install hub skills
146
- await installHubSkills(getHubBasePath());
146
+ // Install hub skills. The published npm package ships only bin/ + lib/,
147
+ // so hub-base/ doesn't exist next to the installed package — we fetch
148
+ // the API zip into a temp dir and use its hub-base/ subdirectory. Falls
149
+ // back to the local hub-base/ when running from a repo checkout (dev mode).
150
+ const tempDir = mkdtempSync(join(tmpdir(), 'ppcos-init-hub-'));
151
+ let hubBasePath;
152
+ const spinner = ora('Downloading hub skills...').start();
153
+ try {
154
+ await fetchSkills(tempDir);
155
+ spinner.succeed('Hub skills downloaded');
156
+ hubBasePath = join(tempDir, 'hub-base');
157
+ } catch (err) {
158
+ spinner.fail('Failed to download hub skills');
159
+ const localHubBase = getHubBasePath();
160
+ if (existsSync(localHubBase)) {
161
+ logger.warn('Using local hub-base/ (dev mode)');
162
+ hubBasePath = localHubBase;
163
+ } else {
164
+ logger.error(err.message);
165
+ logger.error('Hub skills not installed. Run: ppcos login, then ppcos init-all');
166
+ rmSync(tempDir, { recursive: true, force: true });
167
+ printNextSteps();
168
+ return;
169
+ }
170
+ }
171
+
172
+ try {
173
+ await installHubSkills(hubBasePath);
174
+ } finally {
175
+ rmSync(tempDir, { recursive: true, force: true });
176
+ }
177
+
178
+ printNextSteps();
179
+ }
147
180
 
181
+ function printNextSteps() {
148
182
  console.log('');
149
183
  console.log('Next steps:');
150
184
  console.log(' 1. Edit main-config.json with your client names');
@@ -193,7 +227,7 @@ export default async function init(clientName, options = {}) {
193
227
  // 3. Fetch skills from API (with local fallback for dev/testing)
194
228
  const spinner = ora('Fetching skills from API...').start();
195
229
  try {
196
- await fetchSkills(clientDir);
230
+ await fetchSkills(clientDir, { excludeTopLevel: ['hub-base'] });
197
231
  spinner.succeed('Skills downloaded');
198
232
  } catch (error) {
199
233
  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.2",
4
4
  "description": "CLI tool to manage Google Ads AI workflow skills and agents for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {