skill-automation-package 0.2.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,402 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const { spawn, spawnSync } = require("child_process");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+
9
+ const MIN_PYTHON = { major: 3, minor: 10 };
10
+ const INSTALL_METADATA_PATH = path.join(".claude", "skill-automation-package.json");
11
+
12
+ function main() {
13
+ const [, , subcommand, ...forwardedArgs] = process.argv;
14
+
15
+ if (subcommand !== "install" && subcommand !== "update") {
16
+ printUsage(subcommand);
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+
21
+ const packageRoot = path.resolve(__dirname, "..");
22
+ const installerPath = path.resolve(packageRoot, "scripts", "install.py");
23
+ if (!fs.existsSync(installerPath)) {
24
+ console.error(
25
+ `skill-automation-package: expected installer at ${installerPath}, but it was not found.`,
26
+ );
27
+ console.error("Reinstall the package or run it from a complete package checkout.");
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+
32
+ const packageInfo = readPackageInfo(packageRoot);
33
+ if (!packageInfo.ok) {
34
+ console.error(packageInfo.message);
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+
39
+ const targetRoot = resolveTargetRoot(forwardedArgs);
40
+ if (subcommand === "update" && !targetRoot) {
41
+ console.error("skill-automation-package: update requires --target <repo>.");
42
+ printUsage();
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+
47
+ const installState = targetRoot ? detectInstallState(targetRoot, packageInfo.version) : null;
48
+ if (subcommand === "update") {
49
+ const updateDecision = handleUpdateState(installState, packageInfo.version);
50
+ if (updateDecision === "stop-success") {
51
+ process.exitCode = 0;
52
+ return;
53
+ }
54
+ if (updateDecision === "stop-failure") {
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ } else if (installState) {
59
+ printInstallState(installState, packageInfo.version);
60
+ }
61
+
62
+ const python = findPython();
63
+ if (!python.ok) {
64
+ printPythonError(python);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+
69
+ const child = spawn(python.command, [...python.args, installerPath, ...forwardedArgs], {
70
+ stdio: "inherit",
71
+ });
72
+
73
+ child.on("error", (error) => {
74
+ console.error(`skill-automation-package: failed to launch ${python.display}.`);
75
+ console.error(error.message);
76
+ process.exitCode = 1;
77
+ });
78
+
79
+ child.on("exit", (code, signal) => {
80
+ if (signal) {
81
+ process.kill(process.pid, signal);
82
+ return;
83
+ }
84
+ process.exitCode = code === null ? 1 : code;
85
+ });
86
+ }
87
+
88
+ function readPackageInfo(packageRoot) {
89
+ const packageJsonPath = path.join(packageRoot, "package.json");
90
+ if (!fs.existsSync(packageJsonPath)) {
91
+ return {
92
+ ok: false,
93
+ message: `skill-automation-package: expected package metadata at ${packageJsonPath}, but it was not found.`,
94
+ };
95
+ }
96
+
97
+ try {
98
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
99
+ const manifest = JSON.parse(raw);
100
+ if (typeof manifest.version !== "string" || manifest.version.trim() === "") {
101
+ return {
102
+ ok: false,
103
+ message: `skill-automation-package: package metadata at ${packageJsonPath} does not contain a valid version string.`,
104
+ };
105
+ }
106
+ return { ok: true, version: manifest.version.trim() };
107
+ } catch (error) {
108
+ return {
109
+ ok: false,
110
+ message: `skill-automation-package: failed to read package metadata from ${packageJsonPath}: ${error.message}`,
111
+ };
112
+ }
113
+ }
114
+
115
+ function resolveTargetRoot(forwardedArgs) {
116
+ let targetValue = null;
117
+
118
+ for (let index = 0; index < forwardedArgs.length; index += 1) {
119
+ const argument = forwardedArgs[index];
120
+
121
+ if (argument === "--target" && index + 1 < forwardedArgs.length) {
122
+ targetValue = forwardedArgs[index + 1];
123
+ index += 1;
124
+ continue;
125
+ }
126
+
127
+ if (argument.startsWith("--target=")) {
128
+ targetValue = argument.slice("--target=".length);
129
+ }
130
+ }
131
+
132
+ if (!targetValue) {
133
+ return null;
134
+ }
135
+
136
+ return path.resolve(process.cwd(), targetValue);
137
+ }
138
+
139
+ function detectInstallState(targetRoot, currentVersion) {
140
+ const metadataPath = path.join(targetRoot, INSTALL_METADATA_PATH);
141
+ if (!fs.existsSync(metadataPath)) {
142
+ return { status: "not-installed", targetRoot, metadataPath };
143
+ }
144
+
145
+ try {
146
+ const raw = fs.readFileSync(metadataPath, "utf8");
147
+ const metadata = JSON.parse(raw);
148
+ if (typeof metadata.version !== "string" || metadata.version.trim() === "") {
149
+ return { status: "unknown", targetRoot, metadataPath, reason: "missing-version" };
150
+ }
151
+
152
+ const installedVersion = metadata.version.trim();
153
+ const installedParsed = parseVersion(installedVersion);
154
+ const currentParsed = parseVersion(currentVersion);
155
+ if (!installedParsed || !currentParsed) {
156
+ return { status: "unknown", targetRoot, metadataPath, installedVersion, reason: "invalid-version" };
157
+ }
158
+
159
+ const comparison = compareVersions(installedParsed, currentParsed);
160
+ if (comparison === 0) {
161
+ return { status: "same-version", targetRoot, metadataPath, installedVersion };
162
+ }
163
+ if (comparison < 0) {
164
+ return { status: "update-available", targetRoot, metadataPath, installedVersion };
165
+ }
166
+ return { status: "newer-installed", targetRoot, metadataPath, installedVersion };
167
+ } catch (_error) {
168
+ return { status: "unknown", targetRoot, metadataPath, reason: "invalid-json" };
169
+ }
170
+ }
171
+
172
+ function compareVersions(left, right) {
173
+ if (left.major !== right.major) {
174
+ return left.major - right.major;
175
+ }
176
+ if (left.minor !== right.minor) {
177
+ return left.minor - right.minor;
178
+ }
179
+ return left.patch - right.patch;
180
+ }
181
+
182
+ function printInstallState(state, currentVersion) {
183
+ switch (state.status) {
184
+ case "not-installed":
185
+ console.log(
186
+ `skill-automation-package: target is not installed yet; proceeding with install of version ${currentVersion}.`,
187
+ );
188
+ return;
189
+ case "same-version":
190
+ console.log(
191
+ `skill-automation-package: target is already at version ${state.installedVersion}; reinstalling anyway.`,
192
+ );
193
+ printReinstallSafety();
194
+ return;
195
+ case "update-available":
196
+ console.log(
197
+ `skill-automation-package: update available: ${state.installedVersion} -> ${currentVersion}`,
198
+ );
199
+ printReinstallSafety();
200
+ return;
201
+ case "newer-installed":
202
+ console.log(
203
+ `skill-automation-package: target reports version ${state.installedVersion}, which is newer than this package version ${currentVersion}.`,
204
+ );
205
+ console.log(
206
+ `skill-automation-package: reinstalling will replace packaged files with version ${currentVersion}.`,
207
+ );
208
+ printReinstallSafety();
209
+ return;
210
+ default:
211
+ console.log(describeUnknownMetadata(state, "install", currentVersion));
212
+ printLifecycleSafety();
213
+ }
214
+ }
215
+
216
+ function handleUpdateState(state, currentVersion) {
217
+ switch (state.status) {
218
+ case "not-installed":
219
+ console.error("skill-automation-package: target is not installed; use install instead.");
220
+ return "stop-failure";
221
+ case "same-version":
222
+ console.log(`skill-automation-package: already up to date (${currentVersion}).`);
223
+ return "stop-success";
224
+ case "update-available":
225
+ console.log(`skill-automation-package: updating ${state.installedVersion} -> ${currentVersion}`);
226
+ printLifecycleSafety();
227
+ return "proceed";
228
+ case "newer-installed":
229
+ console.error(
230
+ `skill-automation-package: target reports version ${state.installedVersion}, which is newer than this package version ${currentVersion}.`,
231
+ );
232
+ console.error(
233
+ "skill-automation-package: update is blocked to avoid an implicit downgrade.",
234
+ );
235
+ return "stop-failure";
236
+ default:
237
+ console.error(describeUnknownMetadata(state, "update", currentVersion));
238
+ return "stop-failure";
239
+ }
240
+ }
241
+
242
+ function printReinstallSafety() {
243
+ printLifecycleSafety();
244
+ }
245
+
246
+ function printLifecycleSafety() {
247
+ console.log("skill-automation-package: packaged files will be overwritten.");
248
+ console.log(
249
+ "skill-automation-package: repo-local skills, usage tracking, and archived skills will be preserved.",
250
+ );
251
+ }
252
+
253
+ function describeUnknownMetadata(state, mode, currentVersion) {
254
+ const metadataPath = state?.metadataPath || INSTALL_METADATA_PATH;
255
+ const reasonLabel = {
256
+ "invalid-json": "could not be parsed as JSON",
257
+ "missing-version": "does not contain a usable version",
258
+ "invalid-version": "contains a version that could not be compared",
259
+ };
260
+ const detail = reasonLabel[state?.reason] || "could not be compared";
261
+
262
+ if (mode === "install") {
263
+ return `skill-automation-package: existing install metadata at ${metadataPath} ${detail}; reinstalling with version ${currentVersion}.`;
264
+ }
265
+
266
+ return `skill-automation-package: existing install metadata at ${metadataPath} ${detail}; use install to force a reinstall.`;
267
+ }
268
+
269
+ function printUsage(subcommand) {
270
+ if (subcommand) {
271
+ console.error(`skill-automation-package: unsupported subcommand "${subcommand}".`);
272
+ }
273
+ console.error(
274
+ "Usage: skill-automation-package <install|update> --target <repo> [installer options]",
275
+ );
276
+ console.error("`install` always reinstalls. `update` is version-aware and may no-op.");
277
+ }
278
+
279
+ function findPython() {
280
+ const candidates = [
281
+ { command: "python3", args: [], display: "python3" },
282
+ { command: "python", args: [], display: "python" },
283
+ ];
284
+
285
+ if (process.platform === "win32") {
286
+ candidates.push({ command: "py", args: ["-3"], display: "py -3" });
287
+ }
288
+
289
+ const unsuitable = [];
290
+
291
+ for (const candidate of candidates) {
292
+ const probe = probePython(candidate);
293
+ if (probe.ok) {
294
+ return probe;
295
+ }
296
+ if (probe.reason === "version") {
297
+ unsuitable.push(probe);
298
+ }
299
+ }
300
+
301
+ if (unsuitable.length > 0) {
302
+ return { ok: false, reason: "version", unsuitable };
303
+ }
304
+
305
+ return { ok: false, reason: "missing", attempted: candidates.map((candidate) => candidate.display) };
306
+ }
307
+
308
+ function probePython(candidate) {
309
+ const versionScript =
310
+ "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}')";
311
+ const result = spawnSync(candidate.command, [...candidate.args, "-c", versionScript], {
312
+ encoding: "utf8",
313
+ });
314
+
315
+ if (result.error) {
316
+ return { ok: false, reason: "missing", display: candidate.display };
317
+ }
318
+
319
+ if (typeof result.status !== "number" || result.status !== 0) {
320
+ return {
321
+ ok: false,
322
+ reason: "missing",
323
+ display: candidate.display,
324
+ stderr: (result.stderr || "").trim(),
325
+ };
326
+ }
327
+
328
+ const version = parseVersion(result.stdout);
329
+ if (!version) {
330
+ return {
331
+ ok: false,
332
+ reason: "missing",
333
+ display: candidate.display,
334
+ stderr: "Could not parse Python version output.",
335
+ };
336
+ }
337
+
338
+ if (!meetsMinimum(version, MIN_PYTHON)) {
339
+ return {
340
+ ok: false,
341
+ reason: "version",
342
+ command: candidate.command,
343
+ args: candidate.args,
344
+ display: candidate.display,
345
+ version,
346
+ };
347
+ }
348
+
349
+ return {
350
+ ok: true,
351
+ command: candidate.command,
352
+ args: candidate.args,
353
+ display: candidate.display,
354
+ version,
355
+ };
356
+ }
357
+
358
+ function parseVersion(output) {
359
+ const trimmed = (output || "").trim();
360
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(trimmed);
361
+ if (!match) {
362
+ return null;
363
+ }
364
+ return {
365
+ major: Number(match[1]),
366
+ minor: Number(match[2]),
367
+ patch: Number(match[3]),
368
+ raw: trimmed,
369
+ };
370
+ }
371
+
372
+ function meetsMinimum(version, minimum) {
373
+ if (version.major !== minimum.major) {
374
+ return version.major > minimum.major;
375
+ }
376
+ return version.minor >= minimum.minor;
377
+ }
378
+
379
+ function printPythonError(result) {
380
+ console.error(
381
+ `skill-automation-package: Python ${MIN_PYTHON.major}.${MIN_PYTHON.minor}+ is required to run the installer.`,
382
+ );
383
+
384
+ if (result.reason === "version") {
385
+ for (const candidate of result.unsuitable) {
386
+ console.error(`- Found ${candidate.display} (${candidate.version.raw}), but it is too old.`);
387
+ }
388
+ console.error(
389
+ "Install Python 3.10 or newer, or make a compatible `python3` or `python` available on PATH, then rerun the install command.",
390
+ );
391
+ return;
392
+ }
393
+
394
+ console.error(
395
+ "No supported Python launcher was found on PATH. Looked for `python3`, `python`, and on Windows `py -3`.",
396
+ );
397
+ console.error(
398
+ "Install Python 3.10 or newer, or update your PATH so one of those commands is available, then rerun the install command.",
399
+ );
400
+ }
401
+
402
+ main();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "skill-automation-package",
3
+ "version": "0.2.0",
4
+ "description": "Portable repo-local skill automation bundle for Codex and Claude Code.",
5
+ "bin": {
6
+ "skill-automation-package": "bin/skill-automation-package.js"
7
+ },
8
+ "files": [
9
+ "bin/skill-automation-package.js",
10
+ "scripts/install.py",
11
+ "scripts/package_layout.py",
12
+ "assets/.claude/tools/skill_agent.py",
13
+ "assets/.claude/skills/project-skill-router/",
14
+ "assets/.claude/tests/test_skill_agent.py",
15
+ "templates/agents_block.md",
16
+ "templates/claude_block.md",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "test": "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py'",
25
+ "test:wrapper": "node --test tests/node/wrapper.test.js",
26
+ "test:assets": "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s assets/.claude/tests -p 'test_*.py'",
27
+ "install:dry-run": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/install.py --target /tmp/skill-automation-package-dry-run --dry-run",
28
+ "sync-assets": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/sync_assets.py",
29
+ "release:check": "npm run test && npm run test:assets && npm run test:wrapper && npm run install:dry-run && npm pack --dry-run --cache /tmp/skill-automation-package-npm-cache"
30
+ },
31
+ "managed_assets": [
32
+ ".claude/tools/skill_agent.py",
33
+ ".claude/skills/project-skill-router"
34
+ ],
35
+ "optional_assets": [
36
+ ".claude/tests/test_skill_agent.py"
37
+ ],
38
+ "executable_assets": [
39
+ ".claude/tools/skill_agent.py"
40
+ ],
41
+ "license": "MIT"
42
+ }
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ try:
12
+ from datetime import UTC
13
+ except ImportError:
14
+ from datetime import timezone
15
+
16
+ UTC = timezone.utc
17
+
18
+ from package_layout import (
19
+ ASSETS_ROOT,
20
+ PACKAGE_MANIFEST,
21
+ PackageLayout,
22
+ TEMPLATES_ROOT,
23
+ copy_assets,
24
+ load_package_layout,
25
+ )
26
+
27
+ AGENTS_MARKERS = (
28
+ "<!-- SKILL-AUTOMATION:AGENTS:START -->",
29
+ "<!-- SKILL-AUTOMATION:AGENTS:END -->",
30
+ )
31
+
32
+ CLAUDE_MARKERS = (
33
+ "<!-- SKILL-AUTOMATION:CLAUDE:START -->",
34
+ "<!-- SKILL-AUTOMATION:CLAUDE:END -->",
35
+ )
36
+
37
+
38
+ def main() -> int:
39
+ parser = build_parser()
40
+ args = parser.parse_args()
41
+ layout = load_package_layout(PACKAGE_MANIFEST)
42
+
43
+ target = args.target.resolve()
44
+ if not args.dry_run:
45
+ target.mkdir(parents=True, exist_ok=True)
46
+
47
+ selected_assets = layout.selected_assets(include_optional=not args.no_tests)
48
+ copied_files = copy_assets(
49
+ source_root=ASSETS_ROOT,
50
+ destination_root=target,
51
+ asset_paths=selected_assets,
52
+ executable_assets=layout.executable_assets,
53
+ dry_run=args.dry_run,
54
+ )
55
+ wrote_agents = False
56
+ wrote_claude = False
57
+
58
+ if not args.skip_agents:
59
+ wrote_agents = install_managed_block(
60
+ target_file=target / "AGENTS.md",
61
+ template_path=TEMPLATES_ROOT / "agents_block.md",
62
+ markers=AGENTS_MARKERS,
63
+ title="# AGENTS.md\n\n",
64
+ dry_run=args.dry_run,
65
+ )
66
+
67
+ if not args.skip_claude:
68
+ wrote_claude = install_managed_block(
69
+ target_file=target / "CLAUDE.md",
70
+ template_path=TEMPLATES_ROOT / "claude_block.md",
71
+ markers=CLAUDE_MARKERS,
72
+ title="# CLAUDE.md\n\n",
73
+ dry_run=args.dry_run,
74
+ )
75
+
76
+ manifest_path = target / ".claude" / "skill-automation-package.json"
77
+ wrote_manifest = write_install_manifest(
78
+ manifest_path=manifest_path,
79
+ layout=layout,
80
+ target_root=target,
81
+ copied_files=copied_files,
82
+ dry_run=args.dry_run,
83
+ )
84
+
85
+ refreshed = False
86
+ if not args.dry_run:
87
+ refresh_registry(target)
88
+ refreshed = True
89
+
90
+ agents_label = "Would update AGENTS.md" if args.dry_run else "Updated AGENTS.md"
91
+ claude_label = "Would update CLAUDE.md" if args.dry_run else "Updated CLAUDE.md"
92
+ manifest_label = "Would write install manifest" if args.dry_run else "Wrote install manifest"
93
+ print(f"Installed skill automation package {layout.version} into {target}")
94
+ print(f"Copied files: {len(copied_files)}")
95
+ print(f"{agents_label}: {'yes' if wrote_agents else 'no'}")
96
+ print(f"{claude_label}: {'yes' if wrote_claude else 'no'}")
97
+ print(f"{manifest_label}: {'yes' if wrote_manifest else 'no'}")
98
+ print(f"Refreshed registry: {'yes' if refreshed else 'no'}")
99
+ return 0
100
+
101
+
102
+ def build_parser() -> argparse.ArgumentParser:
103
+ parser = argparse.ArgumentParser(
104
+ description="Install the repo-local skill automation bundle into another directory."
105
+ )
106
+ parser.add_argument(
107
+ "--target",
108
+ type=Path,
109
+ required=True,
110
+ help="Target repository or directory that should receive the automation bundle.",
111
+ )
112
+ parser.add_argument(
113
+ "--no-tests",
114
+ action="store_true",
115
+ help="Do not install the packaged verification test.",
116
+ )
117
+ parser.add_argument(
118
+ "--skip-agents",
119
+ action="store_true",
120
+ help="Do not create or update AGENTS.md.",
121
+ )
122
+ parser.add_argument(
123
+ "--skip-claude",
124
+ action="store_true",
125
+ help="Do not create or update CLAUDE.md.",
126
+ )
127
+ parser.add_argument(
128
+ "--dry-run",
129
+ action="store_true",
130
+ help="Preview the installation without writing files.",
131
+ )
132
+ return parser
133
+
134
+
135
+ def install_managed_block(
136
+ *,
137
+ target_file: Path,
138
+ template_path: Path,
139
+ markers: tuple[str, str],
140
+ title: str,
141
+ dry_run: bool,
142
+ ) -> bool:
143
+ block = template_path.read_text(encoding="utf-8").strip() + "\n"
144
+ start_marker, end_marker = markers
145
+ existing = target_file.read_text(encoding="utf-8") if target_file.exists() else ""
146
+ updated = upsert_block(existing, block, start_marker, end_marker, title)
147
+
148
+ if not dry_run:
149
+ target_file.parent.mkdir(parents=True, exist_ok=True)
150
+ target_file.write_text(updated, encoding="utf-8")
151
+ return updated != existing
152
+
153
+
154
+ def upsert_block(
155
+ existing: str,
156
+ block: str,
157
+ start_marker: str,
158
+ end_marker: str,
159
+ title: str,
160
+ ) -> str:
161
+ if not existing.strip():
162
+ return f"{title}{block}"
163
+
164
+ start_index = existing.find(start_marker)
165
+ end_index = existing.find(end_marker)
166
+ if start_index != -1 and end_index != -1 and end_index >= start_index:
167
+ end_index += len(end_marker)
168
+ replacement = block.rstrip()
169
+ return (existing[:start_index] + replacement + existing[end_index:]).rstrip() + "\n"
170
+
171
+ base = existing.rstrip() + "\n\n"
172
+ return base + block
173
+
174
+
175
+ def write_install_manifest(
176
+ *,
177
+ manifest_path: Path,
178
+ layout: PackageLayout,
179
+ target_root: Path,
180
+ copied_files: list[Path],
181
+ dry_run: bool,
182
+ ) -> bool:
183
+ payload = {
184
+ "name": layout.name,
185
+ "version": layout.version,
186
+ "installed_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
187
+ "assets": [str(path.relative_to(target_root)) for path in copied_files],
188
+ }
189
+ if dry_run:
190
+ return False
191
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
192
+ manifest_path.write_text(
193
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
194
+ encoding="utf-8",
195
+ )
196
+ return True
197
+
198
+
199
+ def refresh_registry(target: Path) -> None:
200
+ subprocess.run(
201
+ [sys.executable, str(target / ".claude" / "tools" / "skill_agent.py"), "refresh"],
202
+ check=True,
203
+ cwd=target,
204
+ )
205
+
206
+
207
+ if __name__ == "__main__":
208
+ raise SystemExit(main())