@ccoalm/ccl-skills 0.18.7 → 0.18.9

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.
Files changed (23) hide show
  1. package/README.md +69 -2
  2. package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-policy.md +1 -1
  3. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-merge-authorization.sh +12 -2
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +89 -2
  5. package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-post-merge-cleanup.sh +5 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_merge_authorization.sh +38 -0
  7. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_merge_authorization_prompt.sh +11 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +111 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_post_merge_cleanup.sh +12 -0
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +7 -3
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/references/diagnosis-playbook.md +4 -0
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +5 -5
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/resume-paused-delivery.md +8 -0
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +10 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_body_compliance_grading.sh +31 -1
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/hook-authorization.md +3 -1
  17. package/dist/assets/release.json +31 -21
  18. package/dist/auto-update.d.ts +26 -0
  19. package/dist/auto-update.js +551 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +29 -3
  22. package/dist/opencode-adapter.js +1 -1
  23. package/package.json +1 -1
@@ -0,0 +1,551 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, openSync, closeSync, fsyncSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const OWNER = "ccl-skills-auto-update-v1";
7
+ const SOURCES = new Set(["https://github.com/ccoalm/ccl-skills.git", "git@github.com:ccoalm/ccl-skills.git", "ssh://git@github.com/ccoalm/ccl-skills.git"]);
8
+ const REF = "ccl-skills@ccl-skills";
9
+ const LAUNCHCTL = "/bin/launchctl";
10
+ const MAX_LOCK_AGE_MS = 15 * 60 * 1000;
11
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
12
+ const clean = (value) => value.length > 0 && !/[\x00-\x1f\x7f]/.test(value);
13
+ /** Kill the owned process group on timeout/cancellation, including Git children.
14
+ * Output is bounded and never persisted: child diagnostics can contain secrets. */
15
+ export const runCommand = (file, args, env, timeout, options = {}) => new Promise((done) => {
16
+ const child = spawn(file, args, { env, cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
17
+ const stdout = [], stderr = [];
18
+ let stdoutBytes = 0, stderrBytes = 0, failure;
19
+ let grace;
20
+ const signalGroup = (signal) => {
21
+ if (child.pid) {
22
+ try {
23
+ process.kill(-child.pid, signal);
24
+ }
25
+ catch {
26
+ child.kill(signal);
27
+ }
28
+ }
29
+ };
30
+ const kill = (reason) => {
31
+ if (failure)
32
+ return;
33
+ failure = reason;
34
+ if (options.graceful) {
35
+ signalGroup("SIGINT");
36
+ grace = setTimeout(() => { failure = `${reason}-forced-finality-unknown`; signalGroup("SIGKILL"); }, 10000);
37
+ }
38
+ else
39
+ signalGroup("SIGKILL");
40
+ };
41
+ const interrupt = () => kill("interrupted");
42
+ process.once("SIGINT", interrupt);
43
+ process.once("SIGTERM", interrupt);
44
+ const timer = setTimeout(() => kill("timeout"), timeout);
45
+ child.stdout.on("data", (chunk) => {
46
+ if (stdoutBytes + chunk.length > 4 * 1024 * 1024) {
47
+ kill("output-limit");
48
+ return;
49
+ }
50
+ stdoutBytes += chunk.length;
51
+ stdout.push(chunk);
52
+ });
53
+ child.stderr.on("data", (chunk) => {
54
+ if (stderrBytes + chunk.length > 64 * 1024) {
55
+ kill("output-limit");
56
+ return;
57
+ }
58
+ stderrBytes += chunk.length;
59
+ stderr.push(chunk);
60
+ });
61
+ child.on("error", () => { failure ||= "unavailable"; });
62
+ child.on("close", (code) => {
63
+ clearTimeout(timer);
64
+ if (grace)
65
+ clearTimeout(grace);
66
+ process.removeListener("SIGINT", interrupt);
67
+ process.removeListener("SIGTERM", interrupt);
68
+ done({ code, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), ...(failure ? { failure } : {}) });
69
+ });
70
+ });
71
+ // Child cancellation belongs to runCommand; this scope also covers the gaps
72
+ // between children and keeps repeated signals from bypassing owned cleanup.
73
+ function interruptionScope(execute) {
74
+ let interrupted = false;
75
+ const interrupt = () => { interrupted = true; };
76
+ const check = () => {
77
+ if (interrupted)
78
+ throw new Error("Automatic-update operation interrupted; inspect host state before retrying");
79
+ };
80
+ process.on("SIGINT", interrupt);
81
+ process.on("SIGTERM", interrupt);
82
+ const command = async (...args) => {
83
+ check();
84
+ const result = await execute(...args);
85
+ // Promise continuations can outrun queued signal callbacks. Yield to the
86
+ // event loop before accepting a result or starting another command.
87
+ await new Promise(resolve => setImmediate(resolve));
88
+ // Preserve child failures, including forced termination's unknown finality.
89
+ if (!result.failure)
90
+ check();
91
+ return result;
92
+ };
93
+ return { command, dispose: () => {
94
+ process.removeListener("SIGINT", interrupt);
95
+ process.removeListener("SIGTERM", interrupt);
96
+ } };
97
+ }
98
+ function owned(path, directory = false) {
99
+ try {
100
+ const s = lstatSync(path);
101
+ if (s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1) || s.uid !== process.getuid?.() || (s.mode & 0o022))
102
+ throw new Error(`Unsafe ownership or file type: ${path}`);
103
+ return true;
104
+ }
105
+ catch (error) {
106
+ if (error.code === "ENOENT")
107
+ return false;
108
+ throw error;
109
+ }
110
+ }
111
+ function directory(path) {
112
+ if (owned(path, true))
113
+ return;
114
+ directory(dirname(path));
115
+ mkdirSync(path, { mode: 0o700 });
116
+ }
117
+ function atomic(path, contents) {
118
+ owned(dirname(path), true);
119
+ owned(path);
120
+ const temp = `${path}.${randomUUID()}.tmp`;
121
+ const fd = openSync(temp, "wx", 0o600);
122
+ try {
123
+ try {
124
+ writeFileSync(fd, contents);
125
+ fsyncSync(fd);
126
+ }
127
+ finally {
128
+ closeSync(fd);
129
+ }
130
+ owned(path);
131
+ renameSync(temp, path);
132
+ }
133
+ finally {
134
+ if (existsSync(temp))
135
+ unlinkSync(temp);
136
+ }
137
+ }
138
+ function context(env, host = "codex") {
139
+ if (host !== "codex" && host !== "opencode")
140
+ throw new Error("Unsupported auto-update host");
141
+ if (!env.HOME || !isAbsolute(env.HOME) || !clean(env.HOME))
142
+ throw new Error("HOME must be an existing absolute directory");
143
+ const home = realpathSync(env.HOME), profileInput = host === "codex" ? env.CODEX_HOME || join(home, ".codex") : join(home, ".config", "opencode");
144
+ if (!isAbsolute(profileInput) || !clean(profileInput))
145
+ throw new Error("Host profile must be an existing absolute directory");
146
+ if (!existsSync(profileInput))
147
+ throw new Error(`${host} profile is absent; install CCL for this host before enabling automatic updates`);
148
+ const profile = realpathSync(profileInput), uid = process.getuid?.();
149
+ if (!clean(home) || !clean(profile))
150
+ throw new Error("Canonical HOME and host profile paths must not contain control characters");
151
+ if (uid === undefined || uid === 0)
152
+ throw new Error("Automatic updates require a non-root user");
153
+ owned(home, true);
154
+ owned(profile, true);
155
+ owned(join(home, "Library"), true);
156
+ owned(join(home, "Library", "LaunchAgents"), true);
157
+ const root = join(profile, "ccl-skills-auto-update"), label = `com.ccoalm.ccl-skills.${host}.${digest(profile).slice(0, 16)}`;
158
+ return { host, home, profile, uid, root, label, plist: join(home, "Library", "LaunchAgents", `${label}.plist`), runner: join(root, "runner.mjs"), config: join(root, "state.json"), log: join(root, "last-run.json") };
159
+ }
160
+ function readState(c) {
161
+ if (!owned(c.root, true))
162
+ return null;
163
+ if (!owned(c.config))
164
+ throw new Error("Automatic-update directory has no ownership record; leave it intact and inspect it manually");
165
+ const s = JSON.parse(readFileSync(c.config, "utf8"));
166
+ const keys = ["host", ...(c.host === "opencode" ? ["npm"] : []), "owner", "schema", "home", "profile", "uid", "label", "node", "binary", "path", "runnerHash", "enabled"];
167
+ if (Object.keys(s).sort().join() !== keys.sort().join() || s.host !== c.host || s.owner !== OWNER || s.schema !== 1 || s.home !== c.home || s.profile !== c.profile || s.uid !== c.uid || s.label !== c.label || typeof s.enabled !== "boolean" || !/^[a-f0-9]{64}$/.test(s.runnerHash) || ![s.node, s.binary, s.path].every(v => typeof v === "string" && clean(v)) || !isAbsolute(s.node) || !isAbsolute(s.binary) || !s.path.split(":").every(isAbsolute) || (c.host === "opencode" && (typeof s.npm !== "string" || !clean(s.npm) || !isAbsolute(s.npm))))
168
+ throw new Error("Invalid automatic-update ownership record; no files were changed");
169
+ return s;
170
+ }
171
+ function executable(name, path) {
172
+ for (const dir of path.split(":")) {
173
+ const file = join(dir, name);
174
+ try {
175
+ accessSync(file, constants.X_OK);
176
+ if (lstatSync(realpathSync(file)).isFile())
177
+ return file;
178
+ }
179
+ catch { /* try the next PATH entry */ }
180
+ }
181
+ throw new Error(`${name} executable unavailable; install it and rerun auto-update enable`);
182
+ }
183
+ const runtimeEnv = (s) => s.host === "codex" ? { HOME: s.home, CODEX_HOME: s.profile, PATH: s.path, GIT_TERMINAL_PROMPT: "0" } : { HOME: s.home, PATH: s.path, CCL_SKILLS_SKIP_SELF_UPDATE: "1", CCL_SKILLS_NO_UPDATE_NOTIFIER: "1" };
184
+ const xml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
185
+ function plist(c, s) {
186
+ const string = (v) => `<string>${xml(v)}</string>`;
187
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key>${string(c.label)}\n<key>ProgramArguments</key><array>${[s.node, c.runner, "--run", s.host].map(string).join("")}</array>\n<key>EnvironmentVariables</key><dict>${Object.entries(runtimeEnv(s)).map(([k, v]) => `<key>${k}</key>${string(v)}`).join("")}</dict>\n<key>ExitTimeOut</key><integer>30</integer>\n<key>StartInterval</key><integer>86400</integer>\n<key>RunAtLoad</key><true/>\n<key>WorkingDirectory</key>${string(c.home)}\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
188
+ }
189
+ function verifyFiles(c, s) {
190
+ if (owned(c.runner) && digest(readFileSync(c.runner, "utf8")) !== s.runnerHash)
191
+ throw new Error("Automatic-update runner changed; refusing to replace it");
192
+ if (owned(c.plist) && readFileSync(c.plist, "utf8") !== plist(c, s))
193
+ throw new Error("Schedule is not the owned automatic-update job; refusing to change it");
194
+ }
195
+ async function registration(c, s, command) {
196
+ const r = await command(LAUNCHCTL, ["print", `gui/${c.uid}/${c.label}`], runtimeEnv(s), 10000);
197
+ if (r.failure)
198
+ throw new Error(`launchctl inspection failed (${r.failure}); registration is unknown`);
199
+ if (r.code !== 0) {
200
+ if (/Could not find service|service could not be found/i.test(r.stderr))
201
+ return { registered: false };
202
+ throw new Error("launchctl inspection failed; registration is unknown. Run from your logged-in macOS session");
203
+ }
204
+ const match = r.stdout.match(/^\s*path = (.+)$/m);
205
+ if (!match || match[1] !== c.plist)
206
+ throw new Error("A foreign launchd job uses this label; no scheduling changes made");
207
+ const exit = r.stdout.match(/^\s*last exit code = (\d+)/m);
208
+ return { registered: true, running: /^\s*state = running\s*$/m.test(r.stdout), ...(exit ? { lastExit: Number(exit[1]) } : {}) };
209
+ }
210
+ async function validateCodex(s, command) {
211
+ const query = async (args) => {
212
+ const r = await command(s.binary, ["--no-daemon", "plugin", ...args, "--json"], runtimeEnv(s), 10000);
213
+ if (r.failure || r.code !== 0)
214
+ throw new Error("Codex public JSON query failed; install a Codex CLI supporting --no-daemon and plugin JSON queries, then retry");
215
+ try {
216
+ return JSON.parse(r.stdout);
217
+ }
218
+ catch {
219
+ throw new Error("Codex returned invalid JSON; plugin source is unknown");
220
+ }
221
+ };
222
+ const markets = await query(["marketplace", "list"]);
223
+ if (!Array.isArray(markets.marketplaces))
224
+ throw new Error("Codex marketplace state is unknown");
225
+ const matches = markets.marketplaces.filter((m) => m.name === "ccl-skills");
226
+ if (matches.length !== 1)
227
+ throw new Error("Requires an existing Git marketplace named ccl-skills. npm snapshots are not supported; use ccl-skills update --yes for npm-managed installs");
228
+ const source = matches[0].marketplaceSource;
229
+ if (source?.sourceType !== "git" || !SOURCES.has(source.source))
230
+ throw new Error("Requires the canonical ccl-skills Git marketplace. npm/local snapshots cannot auto-update; use ccl-skills update --yes or explicitly migrate to the Git install described in the README");
231
+ const plugins = await query(["list", "--marketplace", "ccl-skills"]);
232
+ if (!Array.isArray(plugins.installed))
233
+ throw new Error("Codex installed plugin state is unknown");
234
+ const installed = plugins.installed.filter((p) => p.pluginId === REF);
235
+ if (installed.length !== 1 || installed[0].installed !== true || installed[0].enabled !== true || installed[0].marketplaceName !== "ccl-skills" || installed[0].source?.source !== "git" || !SOURCES.has(installed[0].source?.url) || installed[0].marketplaceSource?.sourceType !== "git" || !SOURCES.has(installed[0].marketplaceSource?.source))
236
+ throw new Error("ccl-skills@ccl-skills must be installed and enabled from the canonical Git marketplace before enabling updates");
237
+ }
238
+ const PACKAGE = "@ccoalm/ccl-skills";
239
+ const REGISTRY = "https://registry.npmjs.org/";
240
+ const stableVersion = (value) => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
241
+ function compareVersions(left, right) {
242
+ const a = left.split(".").map(BigInt), b = right.split(".").map(BigInt);
243
+ for (let i = 0; i < 3; i++)
244
+ if (a[i] !== b[i])
245
+ return a[i] > b[i] ? 1 : -1;
246
+ return 0;
247
+ }
248
+ async function packageCommand(s, cli, action, command, cwd = s.home) {
249
+ const args = [cli, action, "--host", "opencode", ...(action === "update" ? ["--yes"] : []), "--json"];
250
+ const result = await command(s.node, args, runtimeEnv(s), action === "update" ? 120000 : 30000, { cwd, graceful: action === "update" });
251
+ if (result.failure)
252
+ throw new Error(`OpenCode ${action} ${result.failure}; inspect installed assets before retrying`);
253
+ let value;
254
+ try {
255
+ value = JSON.parse(result.stdout);
256
+ }
257
+ catch {
258
+ throw new Error(`OpenCode ${action} returned invalid JSON; finality is unknown`);
259
+ }
260
+ if (!value || typeof value !== "object" || ![0, 1, 2, 3, 4, 5, 130].includes(value.code) || result.code !== value.code || typeof value.message !== "string" || typeof value.status !== "string" || !/^[a-z-]+$/.test(value.status))
261
+ throw new Error(`OpenCode ${action} returned an invalid result; finality is unknown`);
262
+ return value;
263
+ }
264
+ function healthyOpenCode(result, expected) {
265
+ if (result.code !== 0 || result.status !== "healthy" || result.details?.sourceKind !== "bundled" || !stableVersion(result.details?.version))
266
+ throw new Error(`OpenCode requires a healthy npm-managed bundled CCL installation (${result.status}). Repair drift first; for source-copy installs, back up the existing CCL files and explicitly migrate with the npm installer before enabling updates`);
267
+ if (expected && result.details.version !== expected)
268
+ throw new Error("OpenCode post-update version does not match the fetched package; finality is unknown");
269
+ return result.details.version;
270
+ }
271
+ async function validateInstallation(s, command) {
272
+ if (s.host === "codex")
273
+ return validateCodex(s, command);
274
+ const cli = fileURLToPath(new URL("./cli.js", import.meta.url));
275
+ healthyOpenCode(await packageCommand(s, cli, "doctor", command));
276
+ }
277
+ function jsonFile(path) {
278
+ if (!owned(path) || lstatSync(path).size > 1024 * 1024)
279
+ throw new Error("Fetched package metadata is missing or oversized");
280
+ return JSON.parse(readFileSync(path, "utf8"));
281
+ }
282
+ function verifyPrivateTree(root) {
283
+ owned(root, true);
284
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
285
+ const path = join(root, entry.name);
286
+ if (entry.isDirectory())
287
+ verifyPrivateTree(path);
288
+ else
289
+ owned(path);
290
+ }
291
+ }
292
+ async function updateOpenCode(c, s, command) {
293
+ const pending = mkdtempSync(join(c.root, "refresh-"));
294
+ try {
295
+ const prefix = join(pending, "package"), cache = join(pending, "cache");
296
+ directory(prefix);
297
+ directory(cache);
298
+ const userConfig = join(pending, "user.npmrc"), globalConfig = join(pending, "global.npmrc");
299
+ atomic(userConfig, "");
300
+ atomic(globalConfig, "");
301
+ atomic(join(prefix, "package.json"), JSON.stringify({ private: true }));
302
+ const result = await command(s.npm, ["install", "--prefix", prefix, "--registry", REGISTRY, "--userconfig", userConfig, "--globalconfig", globalConfig, "--cache", cache, "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--package-lock=false", `${PACKAGE}@latest`], runtimeEnv(s), 180000, { cwd: prefix });
303
+ if (result.failure || result.code !== 0)
304
+ throw new Error(`OpenCode package refresh failed (${result.failure || `exit ${result.code}`}); installed assets were not changed`);
305
+ verifyPrivateTree(prefix);
306
+ const packageRoot = join(prefix, "node_modules", "@ccoalm", "ccl-skills"), pkg = jsonFile(join(packageRoot, "package.json")), release = jsonFile(join(packageRoot, "dist", "assets", "release.json"));
307
+ if (pkg.name !== PACKAGE || !stableVersion(pkg.version) || pkg.type !== "module" || pkg.bin?.["ccl-skills"] !== "dist/cli.js" || release.npmPackage !== PACKAGE || release.version !== pkg.version)
308
+ throw new Error("Fetched OpenCode package identity is invalid; installed assets were not changed");
309
+ const cli = join(packageRoot, "dist", "cli.js");
310
+ if (!owned(cli))
311
+ throw new Error("Fetched package CLI is missing");
312
+ const current = healthyOpenCode(await packageCommand(s, cli, "doctor", command, prefix));
313
+ if (compareVersions(pkg.version, current) < 0)
314
+ return `OpenCode retains newer installed CCL ${current}; registry latest is ${pkg.version}`;
315
+ if (!readState(c)?.enabled)
316
+ throw new Error("Schedule was disabled; OpenCode assets were not changed");
317
+ const updated = await packageCommand(s, cli, "update", command, prefix);
318
+ if (updated.code !== 0 || !["updated", "healthy"].includes(updated.status))
319
+ throw new Error(`OpenCode update returned ${updated.status}; inspect installed assets before retrying`);
320
+ healthyOpenCode(await packageCommand(s, cli, "doctor", command, prefix), pkg.version);
321
+ return `OpenCode CCL assets updated to ${pkg.version}; restart OpenCode to load changes`;
322
+ }
323
+ finally {
324
+ // Only this invocation's exclusive, freshly created prefix is removed.
325
+ owned(c.root, true);
326
+ owned(pending, true);
327
+ rmSync(pending, { recursive: true, force: true });
328
+ }
329
+ }
330
+ function lastRun(c) {
331
+ if (!owned(c.log))
332
+ return null;
333
+ const text = readFileSync(c.log, "utf8");
334
+ if (text.length > 16384)
335
+ throw new Error("Automatic-update run record exceeds its size limit");
336
+ const record = JSON.parse(text);
337
+ if (!record || record.owner !== OWNER || !["running", "success", "failed"].includes(record.status) || typeof record.message !== "string" || typeof record.finishedAt !== "string")
338
+ throw new Error("Invalid automatic-update run record; inspect the log before retrying");
339
+ return record;
340
+ }
341
+ function lockState(path) {
342
+ if (!owned(path))
343
+ return "absent";
344
+ const pid = Number(readFileSync(path, "utf8"));
345
+ if (!Number.isSafeInteger(pid) || pid <= 0)
346
+ throw new Error(`Invalid automatic-update lock: ${path}; inspect it manually`);
347
+ // PID liveness cannot establish process identity after a crash and reuse.
348
+ // A bounded age prevents indefinite healthy overlap; never reclaim the lock.
349
+ if (Math.abs(Date.now() - lstatSync(path).mtimeMs) >= MAX_LOCK_AGE_MS)
350
+ return "uncertain";
351
+ try {
352
+ process.kill(pid, 0);
353
+ return "active";
354
+ }
355
+ catch (error) {
356
+ if (error.code === "ESRCH")
357
+ return "stale";
358
+ throw error;
359
+ }
360
+ }
361
+ function lock(c, name = "run.lock") {
362
+ const path = join(c.root, name), state = lockState(path);
363
+ if (state === "stale")
364
+ throw new Error(`Stale automatic-update lock: ${path}. Confirm its PID has exited, remove only this lock file, then retry`);
365
+ if (state === "uncertain")
366
+ throw new Error(`Automatic-update lock is older than 15 minutes or its timestamp is uncertain: ${path}. Inspect the PID and job; remove only this lock after confirming no updater owns it`);
367
+ if (state === "active")
368
+ throw new Error("Automatic-update operation already running; retry after it finishes");
369
+ try {
370
+ const fd = openSync(path, "wx", 0o600);
371
+ try {
372
+ writeFileSync(fd, String(process.pid));
373
+ }
374
+ finally {
375
+ closeSync(fd);
376
+ }
377
+ }
378
+ catch (error) {
379
+ if (error.code === "EEXIST")
380
+ throw new Error("Automatic-update operation already running; retry after it finishes");
381
+ throw error;
382
+ }
383
+ return () => { if (owned(path) && readFileSync(path, "utf8") === String(process.pid))
384
+ unlinkSync(path); };
385
+ }
386
+ export async function autoUpdate(action, deps = {}) {
387
+ if ((deps.platform || process.platform) !== "darwin")
388
+ return { code: 4, status: "unsupported-platform", message: "Automatic updates currently require macOS launchd. Use the documented manual update commands on other platforms." };
389
+ const env = deps.env || process.env;
390
+ const scope = interruptionScope(deps.command || runCommand), command = scope.command;
391
+ let release;
392
+ try {
393
+ const c = context(env, deps.host);
394
+ const name = c.host === "codex" ? "Codex" : "OpenCode";
395
+ let s = readState(c);
396
+ if (!s && action !== "enable")
397
+ return { code: 0, status: "disabled", message: `${name} automatic updates are not configured for this profile.` };
398
+ if (s)
399
+ verifyFiles(c, s);
400
+ if (action === "status") {
401
+ const registrationState = await registration(c, s, command), registered = registrationState.registered, last = lastRun(c);
402
+ const enabled = s.enabled && registered && owned(c.runner) && owned(c.plist);
403
+ const runLock = lockState(join(c.root, "run.lock"));
404
+ const manageLock = lockState(join(c.root, "manage.lock"));
405
+ const staleLock = runLock === "stale" || manageLock === "stale";
406
+ const uncertainLock = runLock === "uncertain" || manageLock === "uncertain";
407
+ const running = registrationState.running || runLock === "active";
408
+ const failed = staleLock || uncertainLock || last?.status === "failed" || !!registrationState.lastExit || (last?.status === "running" && !running);
409
+ const details = { registered, enabled, running, staleLock, uncertainLock, lastExit: registrationState.lastExit, profile: c.profile, label: c.label, log: c.log, lastRun: last };
410
+ if (!enabled) {
411
+ if (registered || s.enabled)
412
+ return { code: 5, status: "scheduler-error", message: "Schedule registration is incomplete; rerun auto-update enable or disable to recover.", details };
413
+ return { code: 0, status: "disabled", message: `${name} automatic updates are disabled.`, details };
414
+ }
415
+ let summary = last?.status || "not yet recorded";
416
+ if (running)
417
+ summary = "running";
418
+ if (failed)
419
+ summary = "failed or interrupted; inspect the run log";
420
+ if (staleLock)
421
+ summary = "stale lock; confirm its PID has exited, remove only that lock file, then retry";
422
+ if (uncertainLock)
423
+ summary = "lock older than 15 minutes or timestamp uncertain; inspect its PID and job before recovery, never remove a running updater's lock";
424
+ return { code: failed ? 5 : 0, status: failed ? "update-failed" : "enabled", message: `${name} automatic updates are scheduled every 24 hours. Last run: ${summary}. Log: ${c.log}`, details };
425
+ }
426
+ if (!s) {
427
+ if (owned(c.plist))
428
+ throw new Error("A schedule already exists without our ownership record; refusing to replace it");
429
+ const path = (env.PATH || "/usr/bin:/bin").split(":").filter(p => isAbsolute(p) && clean(p)).join(":");
430
+ const source = readFileSync(fileURLToPath(import.meta.url), "utf8");
431
+ s = { host: c.host, ...(c.host === "opencode" ? { npm: executable("npm", path) } : {}), owner: OWNER, schema: 1, home: c.home, profile: c.profile, uid: c.uid, label: c.label, node: deps.node || executable("node", path), binary: executable(c.host, path), path, runnerHash: digest(source), enabled: false };
432
+ await validateInstallation(s, command);
433
+ if ((await registration(c, s, command)).registered)
434
+ throw new Error("A schedule is registered without our ownership record; refusing to change it");
435
+ mkdirSync(c.root, { mode: 0o700 });
436
+ atomic(c.config, JSON.stringify(s));
437
+ }
438
+ release = lock(c, "manage.lock");
439
+ s = readState(c);
440
+ verifyFiles(c, s);
441
+ const registered = (await registration(c, s, command)).registered;
442
+ if (action === "disable") {
443
+ s.enabled = false;
444
+ atomic(c.config, JSON.stringify(s));
445
+ if (registered) {
446
+ const result = await command(LAUNCHCTL, ["bootout", `gui/${c.uid}`, c.plist], runtimeEnv(s), 10000);
447
+ if (result.failure || result.code !== 0)
448
+ throw new Error("Schedule was fenced but unload failed; rerun auto-update disable from your logged-in session");
449
+ }
450
+ if ((await registration(c, s, command)).registered)
451
+ throw new Error("Schedule remains registered; disabling is incomplete");
452
+ if (owned(c.plist))
453
+ unlinkSync(c.plist);
454
+ return { code: 0, status: "disabled", message: `${name} automatic updates are disabled. Runner and log retained at ${c.root}.` };
455
+ }
456
+ await validateInstallation(s, command);
457
+ if (registered && s.enabled && owned(c.runner) && owned(c.plist))
458
+ return { code: 0, status: "enabled", message: `${name} automatic updates are already scheduled every 24 hours. Log: ${c.log}`, details: { label: c.label, log: c.log } };
459
+ if (registered)
460
+ throw new Error("An incomplete schedule remains registered; run auto-update disable, then enable");
461
+ const source = readFileSync(fileURLToPath(import.meta.url), "utf8");
462
+ if (!owned(c.runner)) {
463
+ if (s.runnerHash !== digest(source))
464
+ throw new Error("Saved runner is missing and belongs to a different package version; restore the saved runner before retrying");
465
+ atomic(c.runner, source);
466
+ }
467
+ directory(dirname(c.plist));
468
+ atomic(c.plist, plist(c, s));
469
+ s.enabled = true;
470
+ atomic(c.config, JSON.stringify(s));
471
+ const result = await command(LAUNCHCTL, ["bootstrap", `gui/${c.uid}`, c.plist], runtimeEnv(s), 10000);
472
+ if (result.failure || result.code !== 0 || !(await registration(c, s, command)).registered)
473
+ throw new Error("Schedule registration failed; files retained for recovery. Rerun auto-update enable from your logged-in macOS session");
474
+ return { code: 0, status: "enabled", message: `${name} automatic updates are scheduled every 24 hours and at login. Log: ${c.log}`, details: { label: c.label, profile: c.profile, log: c.log } };
475
+ }
476
+ catch (error) {
477
+ return { code: 5, status: "auto-update-error", message: error instanceof Error ? error.message : "Automatic-update operation failed" };
478
+ }
479
+ finally {
480
+ try {
481
+ release?.();
482
+ }
483
+ finally {
484
+ scope.dispose();
485
+ }
486
+ }
487
+ }
488
+ export async function runScheduled(env = process.env, execute = runCommand, host = "codex") {
489
+ const scope = interruptionScope(execute), command = scope.command;
490
+ let release, c;
491
+ try {
492
+ c = context(env, host);
493
+ let s = readState(c);
494
+ if (!s || !s.enabled)
495
+ return { code: 0, status: "disabled", message: "Schedule disabled; no update performed" };
496
+ verifyFiles(c, s);
497
+ release = lock(c);
498
+ s = readState(c);
499
+ if (!s?.enabled)
500
+ return { code: 0, status: "disabled", message: "Schedule disabled; no update performed" };
501
+ const startedAt = new Date().toISOString();
502
+ const record = (status, message) => atomic(c.log, JSON.stringify({ owner: OWNER, status, startedAt, finishedAt: new Date().toISOString(), message }));
503
+ record("running", `Checking the installed ${s.host} CCL assets`);
504
+ try {
505
+ if (s.host === "opencode") {
506
+ const message = await updateOpenCode(c, s, command);
507
+ record("success", message);
508
+ return { code: 0, status: "updated", message };
509
+ }
510
+ await validateCodex(s, command);
511
+ for (const args of [["marketplace", "upgrade", "ccl-skills"], ["add", REF]]) {
512
+ if (!readState(c)?.enabled)
513
+ throw new Error("Schedule was disabled; no further update commands performed");
514
+ const result = await command(s.binary, ["--no-daemon", "plugin", ...args], runtimeEnv(s), 120000);
515
+ if (result.failure || result.code !== 0)
516
+ throw new Error(`${args[0]} failed (${result.failure || `exit ${result.code}`}); inspect Codex and retry on the next scheduled run`);
517
+ await validateCodex(s, command);
518
+ }
519
+ record("success", "Git marketplace refreshed and ccl-skills plugin installed; restart Codex to load changes");
520
+ return { code: 0, status: "updated", message: "ccl-skills Git plugin updated" };
521
+ }
522
+ catch (error) {
523
+ const message = error instanceof Error ? error.message : "Scheduled update failed";
524
+ record("failed", message);
525
+ return { code: 5, status: "update-failed", message };
526
+ }
527
+ }
528
+ catch (error) {
529
+ const message = error instanceof Error ? error.message : "Scheduled update failed";
530
+ if (message.includes("already running"))
531
+ return { code: 0, status: "skipped", message };
532
+ if (release && c) {
533
+ try {
534
+ atomic(c.log, JSON.stringify({ owner: OWNER, status: "failed", finishedAt: new Date().toISOString(), message }));
535
+ }
536
+ catch { /* launchd last exit remains the independent failure signal */ }
537
+ }
538
+ return { code: 5, status: "auto-update-error", message };
539
+ }
540
+ finally {
541
+ try {
542
+ release?.();
543
+ }
544
+ finally {
545
+ scope.dispose();
546
+ }
547
+ }
548
+ }
549
+ if (process.argv[2] === "--run" && process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
550
+ runScheduled(process.env, runCommand, process.argv[3] || "codex").then(result => { process.exitCode = result.code; });
551
+ }
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Worker } from "node:worker_threads";
3
3
  import type { Options, Result } from "./types.js";
4
+ import { type AutoUpdateAction } from "./auto-update.js";
4
5
  type Parsed = {
5
6
  direct?: {
6
7
  code: number;
@@ -10,6 +11,7 @@ type Parsed = {
10
11
  command?: string;
11
12
  options?: Options;
12
13
  json?: boolean;
14
+ autoUpdateAction?: AutoUpdateAction;
13
15
  };
14
16
  export declare function parseArgs(args: string[]): Parsed;
15
17
  export type SupervisorDeps = {
package/dist/cli.js CHANGED
@@ -5,8 +5,9 @@ import { Worker } from "node:worker_threads";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { paths } from "./paths.js";
7
7
  import { cachePath, emitNotice } from "./update-notice.js";
8
+ import { autoUpdate } from "./auto-update.js";
8
9
  const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
9
- const HELP = `ccl-skills — npm snapshot installer for Claude Code, Codex, and OpenCode\n\nUsage:\n ccl-skills install [--host claude|codex|opencode] [--json]\n ccl-skills update [--host claude|codex|opencode] [--yes] [--allow-downgrade] [--json]\n ccl-skills doctor [--host claude|codex|opencode] [--json]\n ccl-skills uninstall [--host claude|codex|opencode] [--yes] [--json]\n\nWithout --host, every installed or package-owned host is selected.\nUpdate and uninstall are dry-run unless --yes is supplied.\nBy default update --yes upgrades the global npm package to @latest before refreshing assets.\nSet CCL_SKILLS_SKIP_SELF_UPDATE=1 for an assets-only refresh; --allow-downgrade always uses the invoked package without installing @latest first.\n\nAn interactive run prints an update notice on stderr at most once a day.\nSet CCL_SKILLS_NO_UPDATE_NOTIFIER=1 (or NO_UPDATE_NOTIFIER) to silence it; it is\nalready silent under --json, in CI, and when output is not a terminal.`;
10
+ const HELP = `ccl-skills — npm snapshot installer for Claude Code, Codex, and OpenCode\n\nUsage:\n ccl-skills install [--host claude|codex|opencode] [--json]\n ccl-skills update [--host claude|codex|opencode] [--yes] [--allow-downgrade] [--json]\n ccl-skills doctor [--host claude|codex|opencode] [--json]\n ccl-skills uninstall [--host claude|codex|opencode] [--yes] [--json]\n ccl-skills auto-update enable|disable|status [--host codex|opencode] [--json]\n\nAuto-update schedules an existing Codex Git or npm-managed OpenCode CCL installation on macOS; default host: codex.\nFor install/update/doctor/uninstall, omitting --host selects every installed or package-owned host.\nUpdate and uninstall are dry-run unless --yes is supplied.\nBy default update --yes upgrades the global npm package to @latest before refreshing assets.\nSet CCL_SKILLS_SKIP_SELF_UPDATE=1 for an assets-only refresh; --allow-downgrade always uses the invoked package without installing @latest first.\n\nAn interactive run prints an update notice on stderr at most once a day.\nSet CCL_SKILLS_NO_UPDATE_NOTIFIER=1 (or NO_UPDATE_NOTIFIER) to silence it; it is\nalready silent under --json, in CI, and when output is not a terminal.`;
10
11
  export function parseArgs(args) {
11
12
  if (!args.length ||
12
13
  args.includes("--help") ||
@@ -16,6 +17,27 @@ export function parseArgs(args) {
16
17
  if (args.includes("--version"))
17
18
  return { direct: { code: 0, stream: "stdout", text: pkg.version } };
18
19
  const command = args[0], flags = args.slice(1);
20
+ if (command === "auto-update") {
21
+ const action = flags[0];
22
+ let host = "codex", json = false, seenHost = false, invalid = false;
23
+ for (let index = 1; index < flags.length; index++) {
24
+ if (flags[index] === "--json" && !json)
25
+ json = true;
26
+ else if (flags[index] === "--host" && !seenHost) {
27
+ seenHost = true;
28
+ const value = flags[++index];
29
+ if (value !== "codex" && value !== "opencode")
30
+ invalid = true;
31
+ else
32
+ host = value;
33
+ }
34
+ else
35
+ invalid = true;
36
+ }
37
+ if (!["enable", "disable", "status"].includes(action) || invalid)
38
+ return { direct: { code: 2, stream: "stderr", text: "Usage: ccl-skills auto-update enable|disable|status [--host codex|opencode] [--json] (macOS; default: codex)." } };
39
+ return { command, autoUpdateAction: action, options: { host }, json };
40
+ }
19
41
  let host;
20
42
  const normalizedFlags = [];
21
43
  for (let index = 0; index < flags.length; index++) {
@@ -192,7 +214,10 @@ export async function main(args) {
192
214
  }
193
215
  const shouldSelfUpdate = parsed.command === "update" && parsed.options.yes && !parsed.options.allowDowngrade && process.env.CCL_SKILLS_SKIP_SELF_UPDATE !== "1";
194
216
  let result;
195
- if (shouldSelfUpdate) {
217
+ if (parsed.autoUpdateAction) {
218
+ result = await autoUpdate(parsed.autoUpdateAction, { host: parsed.options.host });
219
+ }
220
+ else if (shouldSelfUpdate) {
196
221
  const preflight = await supervise("update", { ...parsed.options, yes: false });
197
222
  result = preflight.code === 0 ? selfUpdate(parsed.options) : preflight;
198
223
  }
@@ -204,7 +229,8 @@ export async function main(args) {
204
229
  write(process.stdout, parsed.json
205
230
  ? JSON.stringify(result)
206
231
  : `${result.status}: ${result.message}${result.plan ? `\n${result.plan.map((x) => `- ${x}`).join("\n")}` : ""}`);
207
- notifyUpdate(!!parsed.json);
232
+ if (!parsed.autoUpdateAction)
233
+ notifyUpdate(!!parsed.json);
208
234
  return result.code;
209
235
  }
210
236
  export function isDirectEntrypoint(importMetaUrl, argv1) {
@@ -330,7 +330,7 @@ function doctor(context) {
330
330
  }
331
331
  if (drift.length || reason)
332
332
  return { code: 3, status: "shared-drift", message: "OpenCode shared files changed after npm install; they will never be auto-deleted", details: { drift, ...(reason ? { reason } : {}) } };
333
- return { code: 0, status: "healthy", message: `OpenCode uses @ccoalm/ccl-skills ${manifest.version}` };
333
+ return { code: 0, status: "healthy", message: `OpenCode uses @ccoalm/ccl-skills ${manifest.version}`, details: { version: manifest.version, sourceKind: manifest.sourceKind } };
334
334
  }
335
335
  function installOrUpdate(commandName, options, context) {
336
336
  const p = paths(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccoalm/ccl-skills",
3
- "version": "0.18.7",
3
+ "version": "0.18.9",
4
4
  "description": "Reusable workflows that help coding agents plan, build, test, review, and release software — for Claude Code, Codex, and OpenCode",
5
5
  "keywords": ["skills", "agent-skills", "claude", "claude-code", "codex", "opencode", "agent", "ai", "ai-agents", "cli", "anthropic", "developer-tools"],
6
6
  "type": "module",