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 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
- ## What It Installs
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 Guide
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
- 3. Or, if you already have this repository checked out locally, run the Python installer directly:
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
- 4. The installer will:
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
- Usually ignore:
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 `--skip-agents --skip-claude` if you do not want top-level guidance files touched
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
- Example:
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 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";
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.2.2",
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",
@@ -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
- wrote_agents = False
56
- wrote_claude = False
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
- wrote_agents = install_managed_block(
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
- wrote_claude = install_managed_block(
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"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'}")
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
- ) -> bool:
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())