skill-automation-package 0.2.2 → 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 +63 -22
- package/bin/skill-automation-package.js +40 -6
- package/package.json +3 -2
- package/scripts/install.py +237 -9
package/README.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Portable repo-local skill automation for Codex and Claude Code.
|
|
4
4
|
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
Install into the repository you are already in, then use the installed runtime immediately. The package is `npx`-first, but the installed runtime still uses Python 3.10+.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd your-repo
|
|
11
|
+
npx skill-automation-package install --target .
|
|
12
|
+
python3 .claude/tools/skill_agent.py auto "add tests" --json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- no setup required beyond `npx` and Python 3.10+
|
|
16
|
+
- works on existing repositories
|
|
17
|
+
- safe: uses bounded managed blocks for generated guidance and ignore rules
|
|
18
|
+
|
|
5
19
|
The published npm package is `skill-automation-package`. It is a thin installer frontend over the same Python-based automation bundle; the runtime and install core still live in Python.
|
|
6
20
|
This remains a Python-installed automation bundle, not an npm runtime package.
|
|
7
21
|
|
|
@@ -18,14 +32,7 @@ After installation, an agent can:
|
|
|
18
32
|
- archive low-value skills that stay unused long enough to become cleanup candidates
|
|
19
33
|
- route future Codex and Claude Code sessions through the same workflow
|
|
20
34
|
|
|
21
|
-
##
|
|
22
|
-
|
|
23
|
-
- `.claude/tools/skill_agent.py`
|
|
24
|
-
- five packaged core default skills under `.claude/skills/`: `project-skill-router/` plus read-only helpers for project summary, repo structure analysis, docs entrypoint guidance, and change summary
|
|
25
|
-
- optional `.claude/tests/test_skill_agent.py`
|
|
26
|
-
- managed automation blocks for `AGENTS.md` and `CLAUDE.md`
|
|
27
|
-
|
|
28
|
-
## Quick Start
|
|
35
|
+
## Basic Flow
|
|
29
36
|
|
|
30
37
|
Use the published package to install into another repository:
|
|
31
38
|
|
|
@@ -41,16 +48,10 @@ npx skill-automation-package update --target /path/to/target-repo
|
|
|
41
48
|
|
|
42
49
|
Python 3.10 or newer is still required. The npm entrypoint is a thin wrapper around the Python installer and does not replace the Python core.
|
|
43
50
|
|
|
44
|
-
If you already have this repository checked out locally, the direct Python install path remains fully supported:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
python3 scripts/install.py --target /path/to/target-repo
|
|
48
|
-
```
|
|
49
|
-
|
|
50
51
|
`install` always allows reinstall. `update` is version-aware and only reinstalls when the target reports an older installed version.
|
|
51
52
|
Before reinstalling, the wrapper checks `.claude/skill-automation-package.json` in the target repo and reports whether the target is not installed, already at the current version, or behind the current package version.
|
|
52
53
|
|
|
53
|
-
The installer copies the packaged assets, updates managed blocks in `AGENTS.md` and `CLAUDE.md` unless skipped, writes `.claude/skill-automation-package.json`, and refreshes `.claude/skills/registry.json`.
|
|
54
|
+
The installer copies the packaged assets, updates managed blocks in `AGENTS.md` and `CLAUDE.md` unless skipped, updates a generated-state `.gitignore` block unless disabled, writes `.claude/skill-automation-package.json`, and refreshes `.claude/skills/registry.json`.
|
|
54
55
|
|
|
55
56
|
Then, inside the target repository, start non-trivial work with:
|
|
56
57
|
|
|
@@ -64,6 +65,13 @@ If you want a preview before writing files:
|
|
|
64
65
|
python3 .claude/tools/skill_agent.py auto "<task>" --dry-run --json
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
## What It Installs
|
|
69
|
+
|
|
70
|
+
- `.claude/tools/skill_agent.py`
|
|
71
|
+
- five packaged core default skills under `.claude/skills/`: `project-skill-router/` plus read-only helpers for project summary, repo structure analysis, docs entrypoint guidance, and change summary
|
|
72
|
+
- optional `.claude/tests/test_skill_agent.py`
|
|
73
|
+
- managed automation blocks for `AGENTS.md` and `CLAUDE.md`
|
|
74
|
+
|
|
67
75
|
## Example Outcomes
|
|
68
76
|
|
|
69
77
|
Representative `auto` outcomes look like this.
|
|
@@ -97,7 +105,7 @@ Create a new skill when no strong match exists:
|
|
|
97
105
|
|
|
98
106
|
The exact skill name is inferred from the task, but the flow is stable: reuse when there is a strong match, otherwise scaffold a new reusable local skill and refresh the registry immediately.
|
|
99
107
|
|
|
100
|
-
## Installation
|
|
108
|
+
## Advanced Installation
|
|
101
109
|
|
|
102
110
|
Use this package when you want to install the automation bundle into another repository. The published npm package is the default entrypoint, and the direct Python path remains available when you are working from a local checkout of this repository.
|
|
103
111
|
|
|
@@ -118,18 +126,21 @@ Use this package when you want to install the automation bundle into another rep
|
|
|
118
126
|
npx skill-automation-package install --target /path/to/target-repo
|
|
119
127
|
```
|
|
120
128
|
|
|
121
|
-
|
|
129
|
+
### Manual Installation
|
|
130
|
+
|
|
131
|
+
If you already have this repository checked out locally, the direct Python install path remains fully supported:
|
|
122
132
|
|
|
123
133
|
```bash
|
|
124
134
|
python3 scripts/install.py --target /path/to/target-repo
|
|
125
135
|
```
|
|
126
136
|
|
|
127
|
-
|
|
137
|
+
### What The Installer Writes
|
|
128
138
|
|
|
129
139
|
- copy `.claude/tools/skill_agent.py`
|
|
130
140
|
- copy the packaged core default skills under `.claude/skills/`
|
|
131
141
|
- optionally copy `.claude/tests/test_skill_agent.py`
|
|
132
142
|
- insert managed automation blocks into `AGENTS.md` and `CLAUDE.md`
|
|
143
|
+
- insert a managed generated-state block into `.gitignore` unless disabled
|
|
133
144
|
- write `.claude/skill-automation-package.json`
|
|
134
145
|
- refresh `.claude/skills/registry.json`
|
|
135
146
|
|
|
@@ -175,17 +186,24 @@ You should see the packaged core skills in the list, including the router, and `
|
|
|
175
186
|
- Skip the packaged test file: `python3 scripts/install.py --target /path/to/target-repo --no-tests`
|
|
176
187
|
- Skip managed `AGENTS.md`: `python3 scripts/install.py --target /path/to/target-repo --skip-agents`
|
|
177
188
|
- Skip managed `CLAUDE.md`: `python3 scripts/install.py --target /path/to/target-repo --skip-claude`
|
|
189
|
+
- Disable `.gitignore` updates: `python3 scripts/install.py --target /path/to/target-repo --gitignore-mode none`
|
|
190
|
+
- Ignore the whole local-only install: `python3 scripts/install.py --target /path/to/target-repo --gitignore-mode local-only`
|
|
178
191
|
|
|
179
192
|
## Managed File Behavior
|
|
180
193
|
|
|
181
194
|
- If `AGENTS.md` or `CLAUDE.md` does not exist, install creates the file and inserts the managed block.
|
|
182
195
|
- If both package markers already exist, install replaces only the content inside that managed block.
|
|
183
196
|
- If the markers are missing, install appends the managed block to the end of the existing file.
|
|
197
|
+
- By default, install also creates or updates a managed `.gitignore` block for generated state only.
|
|
198
|
+
- Use `--gitignore-mode none` to leave `.gitignore` untouched, or `--gitignore-mode local-only` when the whole install should stay local to one checkout.
|
|
184
199
|
- `--dry-run` previews the install result without creating the target directory or writing package files, and its status lines use `Would ...` wording for changes that are only being previewed.
|
|
200
|
+
- Dry-run output includes package file groups for files that would be created, updated, left unchanged, or reported as previously installed but no longer shipped.
|
|
201
|
+
- Dry-run output also reports whether each managed guidance file would be created, replaced, appended, left unchanged, or skipped.
|
|
185
202
|
|
|
186
203
|
## Target Repo Git Hygiene
|
|
187
204
|
|
|
188
205
|
Decide up front whether the installed automation should be shared through version control or kept local to one checkout.
|
|
206
|
+
The default installer policy is shared-friendly: it adds only generated-state patterns to `.gitignore`.
|
|
189
207
|
|
|
190
208
|
### Shared Automation In Version Control
|
|
191
209
|
|
|
@@ -198,7 +216,7 @@ Usually commit:
|
|
|
198
216
|
- `.claude/tests/test_skill_agent.py` when installed
|
|
199
217
|
- `AGENTS.md` and `CLAUDE.md` when you want the managed guidance blocks shared with the team
|
|
200
218
|
|
|
201
|
-
|
|
219
|
+
The installer adds this generated-state block by default:
|
|
202
220
|
|
|
203
221
|
```gitignore
|
|
204
222
|
.claude/skills/registry.json
|
|
@@ -219,10 +237,11 @@ Use this when the install is only for one developer checkout and should not affe
|
|
|
219
237
|
|
|
220
238
|
Typical approach:
|
|
221
239
|
|
|
222
|
-
- install with `--
|
|
240
|
+
- install with `--gitignore-mode local-only`
|
|
241
|
+
- add `--skip-agents --skip-claude` if you do not want top-level guidance files touched
|
|
223
242
|
- ignore the installed automation tree and any optional managed docs
|
|
224
243
|
|
|
225
|
-
|
|
244
|
+
The local-only mode manages this block:
|
|
226
245
|
|
|
227
246
|
```gitignore
|
|
228
247
|
.claude/
|
|
@@ -257,7 +276,17 @@ npx skill-automation-package update --target /path/to/target-repo
|
|
|
257
276
|
|
|
258
277
|
- `update` is a version-aware reinstall, not a partial update.
|
|
259
278
|
- `update` blocks implicit downgrade attempts; use `install` only when you intentionally want to replace the target with the current package version.
|
|
260
|
-
- If install metadata is malformed, `update` stops and asks you to use `install` for a deliberate reinstall.
|
|
279
|
+
- If install metadata is malformed, incomplete, for a different package, or missing a usable asset list, `update` stops and asks you to use `install` for a deliberate reinstall.
|
|
280
|
+
|
|
281
|
+
Metadata recovery behavior:
|
|
282
|
+
|
|
283
|
+
| State | `install` behavior | `update` behavior |
|
|
284
|
+
| --- | --- | --- |
|
|
285
|
+
| Missing metadata | Proceeds as a new install | Stops and asks for `install` |
|
|
286
|
+
| Same version | Reinstalls deliberately | No-ops |
|
|
287
|
+
| Older version | Reinstalls deliberately | Runs reinstall |
|
|
288
|
+
| Newer version | Warns and reinstalls deliberately | Blocks downgrade |
|
|
289
|
+
| Malformed or incomplete metadata | Warns and reinstalls deliberately | Stops and asks for `install` |
|
|
261
290
|
|
|
262
291
|
What gets updated in place:
|
|
263
292
|
|
|
@@ -364,6 +393,18 @@ Do not update `CLAUDE.md`:
|
|
|
364
393
|
python3 scripts/install.py --target /path/to/target-repo --skip-claude
|
|
365
394
|
```
|
|
366
395
|
|
|
396
|
+
Do not update `.gitignore`:
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
python3 scripts/install.py --target /path/to/target-repo --gitignore-mode none
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Use a local-only `.gitignore` policy:
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
python3 scripts/install.py --target /path/to/target-repo --gitignore-mode local-only
|
|
406
|
+
```
|
|
407
|
+
|
|
367
408
|
## Package Layout
|
|
368
409
|
|
|
369
410
|
- `assets/.claude/tools/skill_agent.py`: resolver, search, scaffold, usage tracking, refresh review/update, and prune CLI
|
|
@@ -7,6 +7,7 @@ const fs = require("fs");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
|
|
9
9
|
const MIN_PYTHON = { major: 3, minor: 10 };
|
|
10
|
+
const PACKAGE_NAME = "skill-automation-package";
|
|
10
11
|
const INSTALL_METADATA_PATH = path.join(".claude", "skill-automation-package.json");
|
|
11
12
|
|
|
12
13
|
function main() {
|
|
@@ -145,10 +146,35 @@ function detectInstallState(targetRoot, currentVersion) {
|
|
|
145
146
|
try {
|
|
146
147
|
const raw = fs.readFileSync(metadataPath, "utf8");
|
|
147
148
|
const metadata = JSON.parse(raw);
|
|
149
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
150
|
+
return { status: "unknown", targetRoot, metadataPath, reason: "invalid-shape" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (typeof metadata.name !== "string" || metadata.name.trim() === "") {
|
|
154
|
+
return { status: "unknown", targetRoot, metadataPath, reason: "missing-name" };
|
|
155
|
+
}
|
|
156
|
+
const installedName = metadata.name.trim();
|
|
157
|
+
if (installedName !== PACKAGE_NAME) {
|
|
158
|
+
return {
|
|
159
|
+
status: "unknown",
|
|
160
|
+
targetRoot,
|
|
161
|
+
metadataPath,
|
|
162
|
+
reason: "wrong-package",
|
|
163
|
+
installedName,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
148
167
|
if (typeof metadata.version !== "string" || metadata.version.trim() === "") {
|
|
149
168
|
return { status: "unknown", targetRoot, metadataPath, reason: "missing-version" };
|
|
150
169
|
}
|
|
151
170
|
|
|
171
|
+
if (!Array.isArray(metadata.assets)) {
|
|
172
|
+
return { status: "unknown", targetRoot, metadataPath, reason: "invalid-assets" };
|
|
173
|
+
}
|
|
174
|
+
if (!metadata.assets.every((asset) => typeof asset === "string" && asset.trim() !== "")) {
|
|
175
|
+
return { status: "unknown", targetRoot, metadataPath, reason: "invalid-assets" };
|
|
176
|
+
}
|
|
177
|
+
|
|
152
178
|
const installedVersion = metadata.version.trim();
|
|
153
179
|
const installedParsed = parseVersion(installedVersion);
|
|
154
180
|
const currentParsed = parseVersion(currentVersion);
|
|
@@ -252,12 +278,7 @@ function printLifecycleSafety() {
|
|
|
252
278
|
|
|
253
279
|
function describeUnknownMetadata(state, mode, currentVersion) {
|
|
254
280
|
const metadataPath = state?.metadataPath || INSTALL_METADATA_PATH;
|
|
255
|
-
const
|
|
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";
|
|
281
|
+
const detail = describeMetadataProblem(state);
|
|
261
282
|
|
|
262
283
|
if (mode === "install") {
|
|
263
284
|
return `skill-automation-package: existing install metadata at ${metadataPath} ${detail}; reinstalling with version ${currentVersion}.`;
|
|
@@ -266,6 +287,19 @@ function describeUnknownMetadata(state, mode, currentVersion) {
|
|
|
266
287
|
return `skill-automation-package: existing install metadata at ${metadataPath} ${detail}; use install to force a reinstall.`;
|
|
267
288
|
}
|
|
268
289
|
|
|
290
|
+
function describeMetadataProblem(state) {
|
|
291
|
+
const reasonLabel = {
|
|
292
|
+
"invalid-json": "could not be parsed as JSON",
|
|
293
|
+
"invalid-shape": "is not a JSON object",
|
|
294
|
+
"missing-name": "does not contain a usable package name",
|
|
295
|
+
"wrong-package": `belongs to package "${state?.installedName || "(unknown)"}" instead of ${PACKAGE_NAME}`,
|
|
296
|
+
"missing-version": "does not contain a usable version",
|
|
297
|
+
"invalid-version": "contains a version that could not be compared",
|
|
298
|
+
"invalid-assets": "does not contain a usable assets list",
|
|
299
|
+
};
|
|
300
|
+
return reasonLabel[state?.reason] || "could not be compared";
|
|
301
|
+
}
|
|
302
|
+
|
|
269
303
|
function printUsage(subcommand) {
|
|
270
304
|
if (subcommand) {
|
|
271
305
|
console.error(`skill-automation-package: unsupported subcommand "${subcommand}".`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-automation-package",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Portable repo-local skill automation bundle for Codex and Claude Code.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"skill-automation-package": "bin/skill-automation-package.js"
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
"test:wrapper": "node --test tests/node/wrapper.test.js",
|
|
30
30
|
"test:assets": "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s assets/.claude/tests -p 'test_*.py'",
|
|
31
31
|
"install:dry-run": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/install.py --target /tmp/skill-automation-package-dry-run --dry-run",
|
|
32
|
+
"release:integrity": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_release_integrity.py",
|
|
32
33
|
"sync-assets": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/sync_assets.py",
|
|
33
|
-
"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"
|
|
34
|
+
"release:check": "npm run test && npm run test:assets && npm run test:wrapper && npm run release:integrity && npm run install:dry-run && npm pack --dry-run --cache /tmp/skill-automation-package-npm-cache"
|
|
34
35
|
},
|
|
35
36
|
"managed_assets": [
|
|
36
37
|
".claude/tools/skill_agent.py",
|
package/scripts/install.py
CHANGED
|
@@ -5,6 +5,7 @@ import argparse
|
|
|
5
5
|
import json
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
@@ -21,6 +22,7 @@ from package_layout import (
|
|
|
21
22
|
PackageLayout,
|
|
22
23
|
TEMPLATES_ROOT,
|
|
23
24
|
copy_assets,
|
|
25
|
+
iter_asset_files,
|
|
24
26
|
load_package_layout,
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -34,6 +36,44 @@ CLAUDE_MARKERS = (
|
|
|
34
36
|
"<!-- SKILL-AUTOMATION:CLAUDE:END -->",
|
|
35
37
|
)
|
|
36
38
|
|
|
39
|
+
GITIGNORE_MARKERS = (
|
|
40
|
+
"# SKILL-AUTOMATION:GITIGNORE:START",
|
|
41
|
+
"# SKILL-AUTOMATION:GITIGNORE:END",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
GITIGNORE_PATTERNS = {
|
|
45
|
+
"generated": (
|
|
46
|
+
"# Generated skill automation state",
|
|
47
|
+
".claude/skills/registry.json",
|
|
48
|
+
".claude/skills/usage.json",
|
|
49
|
+
".claude/skills/_archived/",
|
|
50
|
+
".claude/skill-automation-package.json",
|
|
51
|
+
".claude/**/__pycache__/",
|
|
52
|
+
".claude/**/*.pyc",
|
|
53
|
+
),
|
|
54
|
+
"local-only": (
|
|
55
|
+
"# Local-only skill automation install",
|
|
56
|
+
".claude/",
|
|
57
|
+
"AGENTS.md",
|
|
58
|
+
"CLAUDE.md",
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class ManagedBlockPlan:
|
|
65
|
+
changed: bool
|
|
66
|
+
state: str
|
|
67
|
+
updated: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class PackageFilePreview:
|
|
72
|
+
would_create: tuple[Path, ...]
|
|
73
|
+
would_update: tuple[Path, ...]
|
|
74
|
+
unchanged: tuple[Path, ...]
|
|
75
|
+
stale: tuple[Path, ...]
|
|
76
|
+
|
|
37
77
|
|
|
38
78
|
def main() -> int:
|
|
39
79
|
parser = build_parser()
|
|
@@ -52,11 +92,11 @@ def main() -> int:
|
|
|
52
92
|
executable_assets=layout.executable_assets,
|
|
53
93
|
dry_run=args.dry_run,
|
|
54
94
|
)
|
|
55
|
-
|
|
56
|
-
|
|
95
|
+
agents_plan = ManagedBlockPlan(changed=False, state="skipped", updated="")
|
|
96
|
+
claude_plan = ManagedBlockPlan(changed=False, state="skipped", updated="")
|
|
57
97
|
|
|
58
98
|
if not args.skip_agents:
|
|
59
|
-
|
|
99
|
+
agents_plan = install_managed_block(
|
|
60
100
|
target_file=target / "AGENTS.md",
|
|
61
101
|
template_path=TEMPLATES_ROOT / "agents_block.md",
|
|
62
102
|
markers=AGENTS_MARKERS,
|
|
@@ -65,7 +105,7 @@ def main() -> int:
|
|
|
65
105
|
)
|
|
66
106
|
|
|
67
107
|
if not args.skip_claude:
|
|
68
|
-
|
|
108
|
+
claude_plan = install_managed_block(
|
|
69
109
|
target_file=target / "CLAUDE.md",
|
|
70
110
|
template_path=TEMPLATES_ROOT / "claude_block.md",
|
|
71
111
|
markers=CLAUDE_MARKERS,
|
|
@@ -73,6 +113,12 @@ def main() -> int:
|
|
|
73
113
|
dry_run=args.dry_run,
|
|
74
114
|
)
|
|
75
115
|
|
|
116
|
+
gitignore_plan = install_gitignore_block(
|
|
117
|
+
target_file=target / ".gitignore",
|
|
118
|
+
mode=args.gitignore_mode,
|
|
119
|
+
dry_run=args.dry_run,
|
|
120
|
+
)
|
|
121
|
+
|
|
76
122
|
manifest_path = target / ".claude" / "skill-automation-package.json"
|
|
77
123
|
wrote_manifest = write_install_manifest(
|
|
78
124
|
manifest_path=manifest_path,
|
|
@@ -90,12 +136,26 @@ def main() -> int:
|
|
|
90
136
|
agents_label = "Would update AGENTS.md" if args.dry_run else "Updated AGENTS.md"
|
|
91
137
|
claude_label = "Would update CLAUDE.md" if args.dry_run else "Updated CLAUDE.md"
|
|
92
138
|
manifest_label = "Would write install manifest" if args.dry_run else "Wrote install manifest"
|
|
139
|
+
copied_label = "Would copy files" if args.dry_run else "Copied files"
|
|
140
|
+
gitignore_label = "Would update .gitignore" if args.dry_run else "Updated .gitignore"
|
|
93
141
|
print(f"Installed skill automation package {layout.version} into {target}")
|
|
94
|
-
print(f"
|
|
95
|
-
print(f"{agents_label}: {'yes' if
|
|
96
|
-
print(f"{claude_label}: {'yes' if
|
|
142
|
+
print(f"{copied_label}: {len(copied_files)}")
|
|
143
|
+
print(f"{agents_label}: {'yes' if agents_plan.changed else 'no'}")
|
|
144
|
+
print(f"{claude_label}: {'yes' if claude_plan.changed else 'no'}")
|
|
145
|
+
print(f"{gitignore_label}: {'yes' if gitignore_plan.changed else 'no'}")
|
|
97
146
|
print(f"{manifest_label}: {'yes' if wrote_manifest else 'no'}")
|
|
98
147
|
print(f"Refreshed registry: {'yes' if refreshed else 'no'}")
|
|
148
|
+
if args.dry_run:
|
|
149
|
+
print_dry_run_preview(
|
|
150
|
+
build_package_file_preview(
|
|
151
|
+
layout=layout,
|
|
152
|
+
target_root=target,
|
|
153
|
+
asset_paths=selected_assets,
|
|
154
|
+
),
|
|
155
|
+
agents_state=agents_plan.state,
|
|
156
|
+
claude_state=claude_plan.state,
|
|
157
|
+
gitignore_state=gitignore_plan.state,
|
|
158
|
+
)
|
|
99
159
|
return 0
|
|
100
160
|
|
|
101
161
|
|
|
@@ -129,6 +189,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
129
189
|
action="store_true",
|
|
130
190
|
help="Preview the installation without writing files.",
|
|
131
191
|
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--gitignore-mode",
|
|
194
|
+
choices=("generated", "local-only", "none"),
|
|
195
|
+
default="generated",
|
|
196
|
+
help=(
|
|
197
|
+
"Manage a package-owned .gitignore block. `generated` ignores only "
|
|
198
|
+
"generated state, `local-only` ignores the whole local install, and "
|
|
199
|
+
"`none` leaves .gitignore untouched."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
132
202
|
return parser
|
|
133
203
|
|
|
134
204
|
|
|
@@ -139,16 +209,85 @@ def install_managed_block(
|
|
|
139
209
|
markers: tuple[str, str],
|
|
140
210
|
title: str,
|
|
141
211
|
dry_run: bool,
|
|
142
|
-
) ->
|
|
212
|
+
) -> ManagedBlockPlan:
|
|
213
|
+
plan = build_managed_block_plan(
|
|
214
|
+
target_file=target_file,
|
|
215
|
+
template_path=template_path,
|
|
216
|
+
markers=markers,
|
|
217
|
+
title=title,
|
|
218
|
+
)
|
|
219
|
+
if not dry_run:
|
|
220
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
target_file.write_text(plan.updated, encoding="utf-8")
|
|
222
|
+
return plan
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def build_managed_block_plan(
|
|
226
|
+
*,
|
|
227
|
+
target_file: Path,
|
|
228
|
+
template_path: Path,
|
|
229
|
+
markers: tuple[str, str],
|
|
230
|
+
title: str,
|
|
231
|
+
) -> ManagedBlockPlan:
|
|
143
232
|
block = template_path.read_text(encoding="utf-8").strip() + "\n"
|
|
144
233
|
start_marker, end_marker = markers
|
|
145
234
|
existing = target_file.read_text(encoding="utf-8") if target_file.exists() else ""
|
|
146
235
|
updated = upsert_block(existing, block, start_marker, end_marker, title)
|
|
236
|
+
state = classify_managed_block_state(existing, updated, start_marker, end_marker)
|
|
237
|
+
return ManagedBlockPlan(changed=updated != existing, state=state, updated=updated)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def install_gitignore_block(
|
|
241
|
+
*,
|
|
242
|
+
target_file: Path,
|
|
243
|
+
mode: str,
|
|
244
|
+
dry_run: bool,
|
|
245
|
+
) -> ManagedBlockPlan:
|
|
246
|
+
if mode == "none":
|
|
247
|
+
return ManagedBlockPlan(changed=False, state="skipped", updated="")
|
|
248
|
+
|
|
249
|
+
block = build_gitignore_block(mode)
|
|
250
|
+
existing = target_file.read_text(encoding="utf-8") if target_file.exists() else ""
|
|
251
|
+
updated = upsert_block(
|
|
252
|
+
existing,
|
|
253
|
+
block,
|
|
254
|
+
GITIGNORE_MARKERS[0],
|
|
255
|
+
GITIGNORE_MARKERS[1],
|
|
256
|
+
"",
|
|
257
|
+
)
|
|
258
|
+
state = classify_managed_block_state(
|
|
259
|
+
existing,
|
|
260
|
+
updated,
|
|
261
|
+
GITIGNORE_MARKERS[0],
|
|
262
|
+
GITIGNORE_MARKERS[1],
|
|
263
|
+
)
|
|
147
264
|
|
|
148
265
|
if not dry_run:
|
|
149
266
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
150
267
|
target_file.write_text(updated, encoding="utf-8")
|
|
151
|
-
return updated != existing
|
|
268
|
+
return ManagedBlockPlan(changed=updated != existing, state=state, updated=updated)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_gitignore_block(mode: str) -> str:
|
|
272
|
+
patterns = GITIGNORE_PATTERNS[mode]
|
|
273
|
+
return "\n".join([GITIGNORE_MARKERS[0], *patterns, GITIGNORE_MARKERS[1]]) + "\n"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def classify_managed_block_state(
|
|
277
|
+
existing: str,
|
|
278
|
+
updated: str,
|
|
279
|
+
start_marker: str,
|
|
280
|
+
end_marker: str,
|
|
281
|
+
) -> str:
|
|
282
|
+
if updated == existing:
|
|
283
|
+
return "unchanged"
|
|
284
|
+
if not existing.strip():
|
|
285
|
+
return "create"
|
|
286
|
+
start_index = existing.find(start_marker)
|
|
287
|
+
end_index = existing.find(end_marker)
|
|
288
|
+
if start_index != -1 and end_index != -1 and end_index >= start_index:
|
|
289
|
+
return "replace"
|
|
290
|
+
return "append"
|
|
152
291
|
|
|
153
292
|
|
|
154
293
|
def upsert_block(
|
|
@@ -204,5 +343,94 @@ def refresh_registry(target: Path) -> None:
|
|
|
204
343
|
)
|
|
205
344
|
|
|
206
345
|
|
|
346
|
+
def build_package_file_preview(
|
|
347
|
+
*,
|
|
348
|
+
layout: PackageLayout,
|
|
349
|
+
target_root: Path,
|
|
350
|
+
asset_paths: list[Path],
|
|
351
|
+
) -> PackageFilePreview:
|
|
352
|
+
current_assets: set[Path] = set()
|
|
353
|
+
would_create: list[Path] = []
|
|
354
|
+
would_update: list[Path] = []
|
|
355
|
+
unchanged: list[Path] = []
|
|
356
|
+
|
|
357
|
+
for source_path, relative_path in iter_asset_files(ASSETS_ROOT, asset_paths):
|
|
358
|
+
current_assets.add(relative_path)
|
|
359
|
+
target_path = target_root / relative_path
|
|
360
|
+
if not target_path.exists():
|
|
361
|
+
would_create.append(relative_path)
|
|
362
|
+
continue
|
|
363
|
+
if files_match(source_path, target_path):
|
|
364
|
+
unchanged.append(relative_path)
|
|
365
|
+
continue
|
|
366
|
+
would_update.append(relative_path)
|
|
367
|
+
|
|
368
|
+
stale = [
|
|
369
|
+
path
|
|
370
|
+
for path in read_previous_install_assets(target_root)
|
|
371
|
+
if path not in current_assets and (target_root / path).exists()
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
return PackageFilePreview(
|
|
375
|
+
would_create=tuple(sorted(would_create)),
|
|
376
|
+
would_update=tuple(sorted(would_update)),
|
|
377
|
+
unchanged=tuple(sorted(unchanged)),
|
|
378
|
+
stale=tuple(sorted(stale)),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def files_match(source_path: Path, target_path: Path) -> bool:
|
|
383
|
+
if not target_path.is_file():
|
|
384
|
+
return False
|
|
385
|
+
return source_path.read_bytes() == target_path.read_bytes()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def read_previous_install_assets(target_root: Path) -> list[Path]:
|
|
389
|
+
manifest_path = target_root / ".claude" / "skill-automation-package.json"
|
|
390
|
+
if not manifest_path.exists():
|
|
391
|
+
return []
|
|
392
|
+
try:
|
|
393
|
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
394
|
+
except json.JSONDecodeError:
|
|
395
|
+
return []
|
|
396
|
+
assets = payload.get("assets")
|
|
397
|
+
if not isinstance(assets, list):
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
previous: list[Path] = []
|
|
401
|
+
for value in assets:
|
|
402
|
+
if not isinstance(value, str):
|
|
403
|
+
continue
|
|
404
|
+
path = Path(value)
|
|
405
|
+
if path.is_absolute():
|
|
406
|
+
continue
|
|
407
|
+
previous.append(path)
|
|
408
|
+
return previous
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def print_dry_run_preview(
|
|
412
|
+
preview: PackageFilePreview,
|
|
413
|
+
*,
|
|
414
|
+
agents_state: str,
|
|
415
|
+
claude_state: str,
|
|
416
|
+
gitignore_state: str,
|
|
417
|
+
) -> None:
|
|
418
|
+
print("Package file preview:")
|
|
419
|
+
print_preview_paths("would create", preview.would_create)
|
|
420
|
+
print_preview_paths("would update", preview.would_update)
|
|
421
|
+
print_preview_paths("unchanged", preview.unchanged)
|
|
422
|
+
print_preview_paths("previously installed but no longer shipped", preview.stale)
|
|
423
|
+
print("Managed file preview:")
|
|
424
|
+
print(f" AGENTS.md: {agents_state}")
|
|
425
|
+
print(f" CLAUDE.md: {claude_state}")
|
|
426
|
+
print(f" .gitignore: {gitignore_state}")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def print_preview_paths(label: str, paths: tuple[Path, ...]) -> None:
|
|
430
|
+
print(f" {label}: {len(paths)}")
|
|
431
|
+
for path in paths:
|
|
432
|
+
print(f" - {path}")
|
|
433
|
+
|
|
434
|
+
|
|
207
435
|
if __name__ == "__main__":
|
|
208
436
|
raise SystemExit(main())
|