@ccoalm/ccl-skills 0.2.0 → 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.
- package/README.md +12 -0
- package/dist/assets/release.json +24 -4
- package/dist/cli.js +23 -2
- package/dist/update-notice.d.ts +71 -0
- package/dist/update-notice.js +173 -0
- package/dist/version-check.d.ts +2 -0
- package/dist/version-check.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,18 @@ ccl-skills uninstall --yes
|
|
|
36
36
|
|
|
37
37
|
After `ccl-skills uninstall --yes`, remove the CLI package itself with `npm uninstall --global @ccoalm/ccl-skills` if it is no longer needed.
|
|
38
38
|
|
|
39
|
+
## Update notice
|
|
40
|
+
|
|
41
|
+
Nothing here updates itself. An interactive run prints a one-line notice on **stderr** when a newer version exists, at most once a day per version:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Update available: 0.2.0 -> 0.3.0
|
|
45
|
+
Run: ccl-skills update --yes
|
|
46
|
+
Silence: CCL_SKILLS_NO_UPDATE_NOTIFIER=1
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The registry check runs at most once every 24 hours in a detached background process, so no invocation waits for the network, and its result is shown by the next run. The notice and its check are both silent under `--json`, in CI, when stdout or stderr is not a terminal, in terminals narrower than 60 columns, and when `CCL_SKILLS_NO_UPDATE_NOTIFIER` or `NO_UPDATE_NOTIFIER` is set. State lives in `version-check.json` under the managed root; a failed check keeps the last known version and never blocks or fails the command.
|
|
50
|
+
|
|
39
51
|
Claude and Codex use package-owned local marketplace snapshots. OpenCode exposes skills, its native plugin, and `ccl-skills/runtime` through shared host directories. The native adapter maps the shipped session, tool, prompt, subagent, and stop behaviors to OpenCode events and covers `edit`, `write`, and `apply_patch`. Installation refuses unknown collisions, and uninstall preserves shared files for manual cleanup instead of guessing ownership.
|
|
40
52
|
|
|
41
53
|
Set `CCL_SKILLS_REPO` to a valid checkout to override only the OpenCode asset source. An invalid override fails before writing host files. Without the variable, installation is offline after npm has downloaded the package.
|
package/dist/assets/release.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"npmPackage": "@ccoalm/ccl-skills",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"sourceCommit": "
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"sourceCommit": "0e8a42aee6ffd691679245331af0ad346baf4fdc",
|
|
6
6
|
"sourceState": "clean",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
@@ -2719,7 +2719,7 @@
|
|
|
2719
2719
|
},
|
|
2720
2720
|
{
|
|
2721
2721
|
"path": "cli.js",
|
|
2722
|
-
"sha256": "
|
|
2722
|
+
"sha256": "5b5ab7f39b7faa81ea209b3e479295da64f2a834969ee5ac8d76354a182c2822",
|
|
2723
2723
|
"mode": 493
|
|
2724
2724
|
},
|
|
2725
2725
|
{
|
|
@@ -2812,6 +2812,26 @@
|
|
|
2812
2812
|
"sha256": "2aeabe2364755dc91d7058bf3e3b8e2bb4086a0392da6b8f87a3892879334915",
|
|
2813
2813
|
"mode": 420
|
|
2814
2814
|
},
|
|
2815
|
+
{
|
|
2816
|
+
"path": "update-notice.d.ts",
|
|
2817
|
+
"sha256": "2caf9b8512c1969c4ecdb31e90de0265ef435b70a9bcca07f649d5055d63c6ab",
|
|
2818
|
+
"mode": 420
|
|
2819
|
+
},
|
|
2820
|
+
{
|
|
2821
|
+
"path": "update-notice.js",
|
|
2822
|
+
"sha256": "da5ceca856f4492afd38010918d4c33c5f8dfc04d490bbf84ff955a8e12f7245",
|
|
2823
|
+
"mode": 420
|
|
2824
|
+
},
|
|
2825
|
+
{
|
|
2826
|
+
"path": "version-check.d.ts",
|
|
2827
|
+
"sha256": "43e818adf60173644896298637f47b01d5819b17eda46eaa32d0c7d64724d012",
|
|
2828
|
+
"mode": 420
|
|
2829
|
+
},
|
|
2830
|
+
{
|
|
2831
|
+
"path": "version-check.js",
|
|
2832
|
+
"sha256": "bd6babcff7eb222ce39f8ed4d8945f55f4004f45d17600fee497ee64357099da",
|
|
2833
|
+
"mode": 420
|
|
2834
|
+
},
|
|
2815
2835
|
{
|
|
2816
2836
|
"path": "version.d.ts",
|
|
2817
2837
|
"sha256": "edc6c24c912373b1ffc68f7b261f2e04d616b0486e9ab2401d1201f094c5a885",
|
|
@@ -2823,5 +2843,5 @@
|
|
|
2823
2843
|
"mode": 420
|
|
2824
2844
|
}
|
|
2825
2845
|
],
|
|
2826
|
-
"snapshotHash": "
|
|
2846
|
+
"snapshotHash": "f339a0ca319bb93956aebf42d2f63143ae37de54484398b0a3f87af313064436"
|
|
2827
2847
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { Worker } from "node:worker_threads";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { paths } from "./paths.js";
|
|
7
|
+
import { cachePath, emitNotice } from "./update-notice.js";
|
|
6
8
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
7
|
-
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.`;
|
|
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.`;
|
|
8
10
|
export function parseArgs(args) {
|
|
9
11
|
if (!args.length ||
|
|
10
12
|
args.includes("--help") ||
|
|
@@ -164,10 +166,28 @@ function selfUpdate(options) {
|
|
|
164
166
|
return { code: 5, status: "package-updated-assets-unknown", message: "npm package updated, but the fresh CLI returned an invalid result" };
|
|
165
167
|
}
|
|
166
168
|
}
|
|
169
|
+
/** Secondary guidance chrome. It is handed a stderr writer only, runs after the
|
|
170
|
+
* command's own output, and schedules its registry check in a detached child,
|
|
171
|
+
* so it can neither reach stdout nor add latency to this invocation. */
|
|
172
|
+
function notifyUpdate(json) {
|
|
173
|
+
emitNotice({
|
|
174
|
+
current: pkg.version,
|
|
175
|
+
now: Date.now(),
|
|
176
|
+
env: process.env,
|
|
177
|
+
stdoutTTY: !!process.stdout.isTTY,
|
|
178
|
+
stderrTTY: !!process.stderr.isTTY,
|
|
179
|
+
columns: process.stdout.columns,
|
|
180
|
+
json,
|
|
181
|
+
}, cachePath(paths().root), {
|
|
182
|
+
stderr: (text) => write(process.stderr, text),
|
|
183
|
+
spawnCheck: () => spawn(process.execPath, [fileURLToPath(new URL("./version-check.js", import.meta.url))], { detached: true, stdio: "ignore" }).unref(),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
167
186
|
export async function main(args) {
|
|
168
187
|
const parsed = parseArgs(args);
|
|
169
188
|
if (parsed.direct) {
|
|
170
189
|
write(parsed.direct.stream === "stdout" ? process.stdout : process.stderr, parsed.direct.text);
|
|
190
|
+
notifyUpdate(false);
|
|
171
191
|
return parsed.direct.code;
|
|
172
192
|
}
|
|
173
193
|
const shouldSelfUpdate = parsed.command === "update" && parsed.options.yes && !parsed.options.allowDowngrade && process.env.CCL_SKILLS_SKIP_SELF_UPDATE !== "1";
|
|
@@ -184,6 +204,7 @@ export async function main(args) {
|
|
|
184
204
|
write(process.stdout, parsed.json
|
|
185
205
|
? JSON.stringify(result)
|
|
186
206
|
: `${result.status}: ${result.message}${result.plan ? `\n${result.plan.map((x) => `- ${x}`).join("\n")}` : ""}`);
|
|
207
|
+
notifyUpdate(!!parsed.json);
|
|
187
208
|
return result.code;
|
|
188
209
|
}
|
|
189
210
|
export function isDirectEntrypoint(importMetaUrl, argv1) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
|
+
export type Cache = {
|
|
3
|
+
latest?: string;
|
|
4
|
+
lastCheckedAt?: string;
|
|
5
|
+
notifiedVersion?: string;
|
|
6
|
+
notifiedAt?: string;
|
|
7
|
+
};
|
|
8
|
+
export type NoticeInput = {
|
|
9
|
+
current: string;
|
|
10
|
+
cache?: Cache;
|
|
11
|
+
now: number;
|
|
12
|
+
env: Record<string, string | undefined>;
|
|
13
|
+
stdoutTTY: boolean;
|
|
14
|
+
stderrTTY: boolean;
|
|
15
|
+
columns?: number;
|
|
16
|
+
json: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type Verdict = {
|
|
19
|
+
show: false;
|
|
20
|
+
reason: string;
|
|
21
|
+
} | {
|
|
22
|
+
show: true;
|
|
23
|
+
reason: "update-available";
|
|
24
|
+
latest: string;
|
|
25
|
+
};
|
|
26
|
+
export declare const TTL_MS: number;
|
|
27
|
+
export declare const CACHE_FILE = "version-check.json";
|
|
28
|
+
/** Numeric precedence. Returns 0 when either side is not a stable version, so
|
|
29
|
+
* an unparseable value can never be reported as newer. */
|
|
30
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
31
|
+
export declare function pickLatest(packument: unknown): string | null;
|
|
32
|
+
/** Fails open toward refreshing: an absent, malformed, or future timestamp all
|
|
33
|
+
* mean "check again", never "assume fresh". */
|
|
34
|
+
export declare function needsRefresh(cache: Cache | undefined, now: number, ttl?: number): boolean;
|
|
35
|
+
export declare function refreshAllowed(input: NoticeInput): boolean;
|
|
36
|
+
export declare function shouldNotify(input: NoticeInput): Verdict;
|
|
37
|
+
/** Plain text only: colour is an enhancement, never the state carrier, and
|
|
38
|
+
* every line fits the narrow-terminal floor this notice is gated on. */
|
|
39
|
+
export declare function formatNotice(current: string, latest: string): string;
|
|
40
|
+
export declare function cachePath(root: string): string;
|
|
41
|
+
export type EmitDeps = {
|
|
42
|
+
stderr: (text: string) => void;
|
|
43
|
+
spawnCheck: () => void;
|
|
44
|
+
};
|
|
45
|
+
/** The only entry the CLI calls. It is handed a stderr writer and nothing that
|
|
46
|
+
* can reach stdout, so the notice structurally cannot pollute primary output.
|
|
47
|
+
* Returns the sanitized reason code; it never throws and never changes exit. */
|
|
48
|
+
export declare function emitNotice(input: NoticeInput, file: string, deps: EmitDeps): string;
|
|
49
|
+
export type RefreshOptions = {
|
|
50
|
+
file: string;
|
|
51
|
+
now?: number;
|
|
52
|
+
fetchImpl?: (url: string, init: unknown) => Promise<{
|
|
53
|
+
ok: boolean;
|
|
54
|
+
json: () => Promise<unknown>;
|
|
55
|
+
}>;
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
};
|
|
58
|
+
/** Runs in a detached child, never on the caller's path. The check time is
|
|
59
|
+
* stamped even on failure so an offline machine retries once per TTL, not once
|
|
60
|
+
* per command; a failed check never erases a previously known version. */
|
|
61
|
+
export declare function refresh(options: RefreshOptions): Promise<void>;
|
|
62
|
+
export declare function readCache(path: string, read?: typeof readFileSync): Cache | undefined;
|
|
63
|
+
export type WriteDeps = {
|
|
64
|
+
writeFileSync?: typeof writeFileSync;
|
|
65
|
+
renameSync?: typeof renameSync;
|
|
66
|
+
unlinkSync?: typeof unlinkSync;
|
|
67
|
+
mkdirSync?: typeof mkdirSync;
|
|
68
|
+
};
|
|
69
|
+
/** Temp file in the target directory, then rename: a reader sees the old cache
|
|
70
|
+
* or the whole new one, never a truncated one, and never an EXDEV failure. */
|
|
71
|
+
export declare function writeCacheAtomic(path: string, cache: Cache, deps?: WriteDeps): void;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Update notice: a terminal side channel, never primary output.
|
|
2
|
+
// Display is bound to eligibility, cache freshness, and cooldown; the network
|
|
3
|
+
// check runs detached so no invocation pays for it. Every failure path is
|
|
4
|
+
// swallowed — a version check must never change what the command itself does.
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync, readFileSync } from "node:fs";
|
|
7
|
+
export const TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
export const CACHE_FILE = "version-check.json";
|
|
9
|
+
const MIN_COLUMNS = 60;
|
|
10
|
+
const OPT_OUT = ["CCL_SKILLS_NO_UPDATE_NOTIFIER", "NO_UPDATE_NOTIFIER"];
|
|
11
|
+
const CI_VARS = [
|
|
12
|
+
"CI",
|
|
13
|
+
"CONTINUOUS_INTEGRATION",
|
|
14
|
+
"BUILD_NUMBER",
|
|
15
|
+
"GITHUB_ACTIONS",
|
|
16
|
+
"GITLAB_CI",
|
|
17
|
+
"BUILDKITE",
|
|
18
|
+
"CIRCLECI",
|
|
19
|
+
"TRAVIS",
|
|
20
|
+
"TEAMCITY_VERSION",
|
|
21
|
+
"JENKINS_URL",
|
|
22
|
+
];
|
|
23
|
+
// Releases accept only stable MAJOR.MINOR.PATCH, so anything else — prerelease,
|
|
24
|
+
// build metadata, or a hostile string — is not a version we will ever suggest.
|
|
25
|
+
const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
26
|
+
const set = (env, names) => names.some((name) => (env[name] ?? "") !== "");
|
|
27
|
+
/** Numeric precedence. Returns 0 when either side is not a stable version, so
|
|
28
|
+
* an unparseable value can never be reported as newer. */
|
|
29
|
+
export function compareVersions(a, b) {
|
|
30
|
+
if (!STABLE.test(a) || !STABLE.test(b))
|
|
31
|
+
return 0;
|
|
32
|
+
const left = a.split(".").map(Number), right = b.split(".").map(Number);
|
|
33
|
+
for (let i = 0; i < 3; i++)
|
|
34
|
+
if (left[i] !== right[i])
|
|
35
|
+
return left[i] - right[i];
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
export function pickLatest(packument) {
|
|
39
|
+
const tags = packument?.["dist-tags"];
|
|
40
|
+
const latest = tags?.latest;
|
|
41
|
+
return typeof latest === "string" && STABLE.test(latest) ? latest : null;
|
|
42
|
+
}
|
|
43
|
+
/** Fails open toward refreshing: an absent, malformed, or future timestamp all
|
|
44
|
+
* mean "check again", never "assume fresh". */
|
|
45
|
+
export function needsRefresh(cache, now, ttl = TTL_MS) {
|
|
46
|
+
const at = Date.parse(cache?.lastCheckedAt ?? "");
|
|
47
|
+
return Number.isNaN(at) || at > now || now - at >= ttl;
|
|
48
|
+
}
|
|
49
|
+
/** The display gates that do not depend on cache contents. Shared by the notice
|
|
50
|
+
* and the background check so `--json`, CI, and opt-out make no network call. */
|
|
51
|
+
function displayAllowed(input) {
|
|
52
|
+
if (set(input.env, OPT_OUT))
|
|
53
|
+
return { ok: false, reason: "opt-out" };
|
|
54
|
+
if (set(input.env, CI_VARS) || !input.stdoutTTY || !input.stderrTTY)
|
|
55
|
+
return { ok: false, reason: "non-interactive" };
|
|
56
|
+
if (input.json)
|
|
57
|
+
return { ok: false, reason: "structured-output" };
|
|
58
|
+
// A pty with no window size reports 0: that is "width unknown", not "zero
|
|
59
|
+
// cells wide", so it must not be treated as a narrow terminal.
|
|
60
|
+
if (input.columns !== undefined && input.columns > 0 && input.columns < MIN_COLUMNS)
|
|
61
|
+
return { ok: false, reason: "narrow-terminal" };
|
|
62
|
+
return { ok: true, reason: "eligible" };
|
|
63
|
+
}
|
|
64
|
+
export function refreshAllowed(input) {
|
|
65
|
+
return displayAllowed(input).ok;
|
|
66
|
+
}
|
|
67
|
+
export function shouldNotify(input) {
|
|
68
|
+
const gate = displayAllowed(input);
|
|
69
|
+
if (!gate.ok)
|
|
70
|
+
return { show: false, reason: gate.reason };
|
|
71
|
+
const latest = input.cache?.latest;
|
|
72
|
+
if (!latest)
|
|
73
|
+
return { show: false, reason: "no-cache" };
|
|
74
|
+
if (compareVersions(latest, input.current) <= 0)
|
|
75
|
+
return { show: false, reason: "up-to-date" };
|
|
76
|
+
const notifiedAt = Date.parse(input.cache?.notifiedAt ?? "");
|
|
77
|
+
if (input.cache?.notifiedVersion === latest &&
|
|
78
|
+
!Number.isNaN(notifiedAt) &&
|
|
79
|
+
input.now - notifiedAt < TTL_MS)
|
|
80
|
+
return { show: false, reason: "cooldown" };
|
|
81
|
+
return { show: true, reason: "update-available", latest };
|
|
82
|
+
}
|
|
83
|
+
/** Plain text only: colour is an enhancement, never the state carrier, and
|
|
84
|
+
* every line fits the narrow-terminal floor this notice is gated on. */
|
|
85
|
+
export function formatNotice(current, latest) {
|
|
86
|
+
return [
|
|
87
|
+
`Update available: ${current} -> ${latest}`,
|
|
88
|
+
"Run: ccl-skills update --yes",
|
|
89
|
+
"Silence: CCL_SKILLS_NO_UPDATE_NOTIFIER=1",
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
export function cachePath(root) {
|
|
93
|
+
return join(root, CACHE_FILE);
|
|
94
|
+
}
|
|
95
|
+
/** The only entry the CLI calls. It is handed a stderr writer and nothing that
|
|
96
|
+
* can reach stdout, so the notice structurally cannot pollute primary output.
|
|
97
|
+
* Returns the sanitized reason code; it never throws and never changes exit. */
|
|
98
|
+
export function emitNotice(input, file, deps) {
|
|
99
|
+
try {
|
|
100
|
+
const cache = input.cache ?? readCache(file);
|
|
101
|
+
const resolved = { ...input, cache };
|
|
102
|
+
const verdict = shouldNotify(resolved);
|
|
103
|
+
if (verdict.show) {
|
|
104
|
+
deps.stderr(formatNotice(input.current, verdict.latest));
|
|
105
|
+
writeCacheAtomic(file, {
|
|
106
|
+
...cache,
|
|
107
|
+
notifiedVersion: verdict.latest,
|
|
108
|
+
notifiedAt: new Date(input.now).toISOString(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (refreshAllowed(resolved) && needsRefresh(cache, input.now))
|
|
112
|
+
deps.spawnCheck();
|
|
113
|
+
return verdict.reason;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return "unavailable";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const REGISTRY = "https://registry.npmjs.org/@ccoalm%2fccl-skills";
|
|
120
|
+
/** Runs in a detached child, never on the caller's path. The check time is
|
|
121
|
+
* stamped even on failure so an offline machine retries once per TTL, not once
|
|
122
|
+
* per command; a failed check never erases a previously known version. */
|
|
123
|
+
export async function refresh(options) {
|
|
124
|
+
const now = options.now ?? Date.now(), previous = readCache(options.file) ?? {};
|
|
125
|
+
let latest = null;
|
|
126
|
+
try {
|
|
127
|
+
const response = await (options.fetchImpl ?? fetch)(REGISTRY, {
|
|
128
|
+
headers: { Accept: "application/vnd.npm.install-v1+json" },
|
|
129
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
|
|
130
|
+
});
|
|
131
|
+
if (response.ok)
|
|
132
|
+
latest = pickLatest(await response.json());
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* offline, throttled, or malformed: keep the previous value */
|
|
136
|
+
}
|
|
137
|
+
writeCacheAtomic(options.file, {
|
|
138
|
+
...previous,
|
|
139
|
+
...(latest ? { latest } : {}),
|
|
140
|
+
lastCheckedAt: new Date(now).toISOString(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
export function readCache(path, read = readFileSync) {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(read(path, "utf8"));
|
|
146
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
147
|
+
? parsed
|
|
148
|
+
: undefined;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Temp file in the target directory, then rename: a reader sees the old cache
|
|
155
|
+
* or the whole new one, never a truncated one, and never an EXDEV failure. */
|
|
156
|
+
export function writeCacheAtomic(path, cache, deps = {}) {
|
|
157
|
+
const write = deps.writeFileSync ?? writeFileSync, rename = deps.renameSync ?? renameSync, unlink = deps.unlinkSync ?? unlinkSync, temp = join(dirname(path), `.${CACHE_FILE}.tmp-${process.pid}`);
|
|
158
|
+
try {
|
|
159
|
+
// Without this the state root is absent until a host install runs, the
|
|
160
|
+
// write fails every time, and the caller reschedules the check forever.
|
|
161
|
+
(deps.mkdirSync ?? mkdirSync)(dirname(path), { recursive: true });
|
|
162
|
+
write(temp, `${JSON.stringify(cache)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
163
|
+
rename(temp, path);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
try {
|
|
167
|
+
unlink(temp);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* nothing to clean up */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Detached entrypoint for the background version check. It is spawned with
|
|
3
|
+
// stdio ignored and unref'd, so it cannot slow, block, or outlive-block the
|
|
4
|
+
// command that scheduled it, and it exits 0 whatever the registry does.
|
|
5
|
+
import { paths } from "./paths.js";
|
|
6
|
+
import { cachePath, refresh } from "./update-notice.js";
|
|
7
|
+
refresh({ file: cachePath(paths().root) }).then(() => process.exit(0), () => process.exit(0));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccoalm/ccl-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Self-contained CCL Skills installer for Claude Code, Codex, and OpenCode",
|
|
5
5
|
"keywords": ["agent-skills", "claude-code", "codex", "opencode", "developer-tools"],
|
|
6
6
|
"type": "module",
|