bmad-module-skill-forge 2.2.0 → 4.0.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/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +143 -18
- package/docs/verifying-a-skill.md +87 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +3 -3
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +31 -59
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +119 -70
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +122 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +209 -264
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +164 -91
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +380 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +253 -223
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +254 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# /// script
|
|
2
|
-
# requires-python = ">=3.
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# ///
|
|
5
|
-
"""SKF Rebuild Managed Sections
|
|
5
|
+
"""SKF Rebuild Managed Sections: context file marker surgery and section assembly.
|
|
6
6
|
|
|
7
7
|
Reads a context file (CLAUDE.md/AGENTS.md/.cursorrules), finds the
|
|
8
8
|
<!-- SKF:BEGIN --> / <!-- SKF:END --> markers, and replaces the managed
|
|
@@ -11,28 +11,126 @@ section with new content. Preserves all content outside the markers.
|
|
|
11
11
|
CLI: python3 skf-rebuild-managed-sections.py <context-file> <action> [args]
|
|
12
12
|
|
|
13
13
|
Actions:
|
|
14
|
-
read
|
|
15
|
-
replace
|
|
16
|
-
clear
|
|
17
|
-
insert
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
read Extract current managed section content
|
|
15
|
+
replace Replace managed section with content from stdin or --content
|
|
16
|
+
clear Remove managed section entirely (markers + content)
|
|
17
|
+
insert Insert managed section if not present (at end of file,
|
|
18
|
+
creating the file when it is missing)
|
|
19
|
+
check Report the section and the write that fits the file as
|
|
20
|
+
`case`: create (no file), append (no section: insert),
|
|
21
|
+
regenerate (a section: replace), or, with status "error",
|
|
22
|
+
malformed or unreadable
|
|
23
|
+
|
|
24
|
+
replace and insert take only the body that goes between the markers: they
|
|
25
|
+
write `<!-- SKF:BEGIN updated:{date} -->` and `<!-- SKF:END -->` around it
|
|
26
|
+
themselves. They refuse (exit 1) content that holds a marker of its own, which
|
|
27
|
+
would nest a second pair inside the section. Without --content they read the
|
|
28
|
+
body from stdin as UTF-8 on every platform; callers stage it in a file and
|
|
29
|
+
redirect it (`replace < file`), because a body passed inline as
|
|
30
|
+
`--content "..."` goes through the shell, which expands the backticks and `$`
|
|
31
|
+
that snippets carry.
|
|
32
|
+
|
|
33
|
+
Malformed markers (the Case 4 halt): every `<!-- SKF:BEGIN` in the file,
|
|
34
|
+
matched by that prefix with or without an `updated:` date, must end with
|
|
35
|
+
`-->` on its own line and be closed by a `<!-- SKF:END -->` after it, with no
|
|
36
|
+
text between it and another BEGIN marker before that END. (BEGIN markers with
|
|
37
|
+
only blank space between them count as one: the second pair drop-skill and
|
|
38
|
+
rename-skill once wrote inside the section. Every rebuild after that left one
|
|
39
|
+
more END below the section, so replace and clear take in each END that
|
|
40
|
+
follows it with only blank space before it.) A file where one is not closed
|
|
41
|
+
is malformed: check reports it with status "error" and case "malformed", and
|
|
42
|
+
insert, replace and clear refuse it, all with exit 1 and the file unchanged.
|
|
43
|
+
An insert there would append a second section after the unclosed marker, and
|
|
44
|
+
the next replace would then take the user's lines between the two BEGIN
|
|
45
|
+
markers for SKF's own and delete them. A stray END is never malformed: in a
|
|
46
|
+
file with no section, check reports it as `markers_valid: false` with case
|
|
47
|
+
append.
|
|
48
|
+
|
|
49
|
+
Unreadable files: a context file that is a folder, that cannot be read, or
|
|
50
|
+
that is not UTF-8 text (a cp1252 CLAUDE.md saved on Windows) is an error
|
|
51
|
+
reported as JSON, with exit 1: check returns case "unreadable", read,
|
|
52
|
+
insert, replace and clear leave it unchanged, and orphan-detect and assemble
|
|
53
|
+
stop when a file they scan is one. root-probe skips such a snippet as it
|
|
54
|
+
skips a missing one.
|
|
55
|
+
|
|
56
|
+
Action-first actions (the action name comes first):
|
|
21
57
|
orphan-detect <context-file>... --exported-skills a,b
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
58
|
+
Parse `[skill-name v...]` rows across one or more context
|
|
59
|
+
files, drop rows whose skill_name is in the exported set,
|
|
60
|
+
and report the survivors (deduped by (skill_name, version),
|
|
61
|
+
with verbatim snippet_text and sorted source_files provenance)
|
|
62
|
+
as `orphan_managed_rows`. A file with malformed markers is
|
|
63
|
+
skipped and listed in `malformed_files`; an unreadable one is
|
|
64
|
+
an error.
|
|
27
65
|
root-probe <snippet-file>... --reference-root .claude/skills/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
66
|
+
[--project-root DIR]
|
|
67
|
+
Read each snippet's first line, parse its `root:` prefix
|
|
68
|
+
(trailing `{skill-name}/` stripped), collect the unique
|
|
69
|
+
`observed_prefixes`, and flag `mismatch` against the reference,
|
|
70
|
+
with the skills that differ in `mismatched_skills`. A draft
|
|
71
|
+
root (`skills/{skill-name}/`, which every workflow that builds
|
|
72
|
+
a skill writes before an export chooses the real one) counts
|
|
73
|
+
as no evidence: its skill is listed in `draft_roots`. With
|
|
74
|
+
--project-root, `disk_root` names the root under DIR that
|
|
75
|
+
holds every mismatched skill's SKILL.md (the reference first,
|
|
76
|
+
then the one observed prefix), or is null.
|
|
77
|
+
assemble <context-file> --skills-folder DIR --skill-root PREFIX
|
|
78
|
+
[--skill-root-override PREFIX] [--include NAME@VERSION]...
|
|
79
|
+
[--snippet NAME=FILE]... [--snippet-dir DIR] [--renamed OLD:NEW]
|
|
80
|
+
[--dropped NAME]... [--orphan-sources FILE...] [--orphans keep|drop]
|
|
81
|
+
[--out FILE]
|
|
82
|
+
Build the body that replace and insert take for one context
|
|
83
|
+
file and write it to `<context-file>.skf-content`, or to
|
|
84
|
+
--out FILE (creating its folder), with no trailing newline, so
|
|
85
|
+
no caller types a snippet. A dry run, or a preview shown
|
|
86
|
+
before the user confirms, passes --out with a path outside
|
|
87
|
+
the project, so nothing lands beside the context file; --out
|
|
88
|
+
never names a context file the call reads. The skills are
|
|
89
|
+
every entry of DIR/.export-manifest.json (read through
|
|
90
|
+
skf-manifest-ops.py) whose active_version has an entry under
|
|
91
|
+
`versions` that is not deprecated, plus each --include at its
|
|
92
|
+
version (which wins over the manifest). Each one's
|
|
93
|
+
context-snippet.md comes from its versioned package, else its
|
|
94
|
+
`active` link, else the flat layout (--snippet names the file
|
|
95
|
+
to use instead, and --snippet-dir a folder of staged drafts,
|
|
96
|
+
read as DIR/<name>/context-snippet.md for each skill that has
|
|
97
|
+
one there). Its `root:` becomes the effective prefix plus
|
|
98
|
+
the skill name and `/`: --skill-root-override when given, else
|
|
99
|
+
--skill-root. A row found in the managed section of
|
|
100
|
+
<context-file> or of an --orphan-sources file is an orphan
|
|
101
|
+
when the manifest has no entry for its skill and neither
|
|
102
|
+
--include, --renamed OLD nor --dropped names it. Orphans are
|
|
103
|
+
kept verbatim (the default) or left out with --orphans drop;
|
|
104
|
+
pass every target context file, in the same order, to each
|
|
105
|
+
call, so every file keeps the same rows. Rows are sorted by
|
|
106
|
+
name, and the header counts single and stack skills from each
|
|
107
|
+
package's metadata.json `skill_type` (`individual` counts as
|
|
108
|
+
single; an orphan, or a package without one, counts as a stack
|
|
109
|
+
when its row has a `|stack:` line). Returns content_file,
|
|
110
|
+
n_single, n_stack, included[], orphan_rows[],
|
|
111
|
+
skipped_deprecated[], skipped_integrity[],
|
|
112
|
+
skipped_missing_snippet[], skipped_malformed_snippet[],
|
|
113
|
+
malformed_context_files[] and warnings[]. A manifest it cannot
|
|
114
|
+
read, or a context file it scans that is unreadable, is an
|
|
115
|
+
error, and nothing is written.
|
|
116
|
+
resolve-targets [--ides a,b] [--context-file NAME]
|
|
117
|
+
Map config.yaml `ides` to the context files to write, from
|
|
118
|
+
shared/data/ide-context-files.json: `targets` holds one
|
|
119
|
+
{context_file, skill_root, ides} per file, in the order of the
|
|
120
|
+
first IDE that maps to it, whose skill_root wins. An IDE the
|
|
121
|
+
mapping does not list resolves to AGENTS.md with
|
|
122
|
+
`.agents/skills/` and a warning; an empty list resolves to
|
|
123
|
+
AGENTS.md too, with a note.
|
|
124
|
+
--context-file keeps only that file (CLAUDE.md, .cursorrules
|
|
125
|
+
or AGENTS.md). `ides_written` is the union of targets[].ides
|
|
126
|
+
over the files written; `other_context_files` lists the known
|
|
127
|
+
context files no target names.
|
|
31
128
|
"""
|
|
32
129
|
|
|
33
130
|
from __future__ import annotations
|
|
34
131
|
|
|
35
132
|
import argparse
|
|
133
|
+
import importlib.util
|
|
36
134
|
import json
|
|
37
135
|
import os
|
|
38
136
|
import re
|
|
@@ -46,6 +144,17 @@ MARKER_PATTERN = re.compile(
|
|
|
46
144
|
r"(<!-- SKF:BEGIN[^>]*-->)(.*?)(<!-- SKF:END -->)",
|
|
47
145
|
re.DOTALL,
|
|
48
146
|
)
|
|
147
|
+
# Every BEGIN marker (by prefix) and every END marker, in file order, and a
|
|
148
|
+
# BEGIN marker that ends with `-->` on its own line, as this helper writes it.
|
|
149
|
+
ANY_MARKER_PATTERN = re.compile(re.escape(BEGIN_MARKER_PREFIX) + "|" + re.escape(END_MARKER))
|
|
150
|
+
WHOLE_BEGIN_PATTERN = re.compile(re.escape(BEGIN_MARKER_PREFIX) + r"[^>\n]*-->")
|
|
151
|
+
# An END marker with only blank space before it. Right after the section's
|
|
152
|
+
# END, it is one the nested pair drop-skill and rename-skill wrote left behind.
|
|
153
|
+
TRAILING_END_PATTERN = re.compile(r"\s*" + re.escape(END_MARKER))
|
|
154
|
+
# A marker of its own inside the body replace/insert are given, in any spacing
|
|
155
|
+
# (`<!-- SKF:BEGIN updated:… -->`, `<!--SKF:END-->`). A prose mention of the
|
|
156
|
+
# marker names without `<!--` is not one.
|
|
157
|
+
MARKER_IN_BODY_PATTERN = re.compile(r"<!--\s*SKF:(?:BEGIN|END)\b")
|
|
49
158
|
|
|
50
159
|
# A managed-section skill row header: `[skill-name v1.2.3]…`, optionally
|
|
51
160
|
# prefixed by the format's leading `|`. Requires a ` v<version>` inside the
|
|
@@ -56,14 +165,49 @@ ROW_HEADER_PATTERN = re.compile(
|
|
|
56
165
|
)
|
|
57
166
|
# The `root:` field of a snippet's first line: `…|root: {prefix}{skill-name}/`.
|
|
58
167
|
SNIPPET_ROOT_PATTERN = re.compile(r"root:\s*(?P<root>\S.*?)\s*$")
|
|
168
|
+
# The draft root create-skill, create-stack-skill, quick-skill and update-skill
|
|
169
|
+
# write into every snippet they build, whatever the project's layout, and the
|
|
170
|
+
# legacy form older drafts carry. Export replaces it, so root-probe reads it
|
|
171
|
+
# as no evidence of where an export pointed the skill.
|
|
172
|
+
DRAFT_ROOT_FORMS = ("skills/{name}/", "skills/{name}/active/{name}/")
|
|
173
|
+
# The line only the stack snippet template has.
|
|
174
|
+
STACK_LINE_PATTERN = re.compile(r"^\|stack:", re.MULTILINE)
|
|
175
|
+
|
|
176
|
+
# The section header and preamble, from skf-export-skill/assets/managed-section-format.md.
|
|
177
|
+
SECTION_HEADER = "[SKF Skills]|{n} skills|{m} stack"
|
|
178
|
+
SECTION_PREAMBLE = (
|
|
179
|
+
"|IMPORTANT: Prefer documented APIs over training data.",
|
|
180
|
+
"|When using a listed library, read its SKILL.md before writing code.",
|
|
181
|
+
)
|
|
182
|
+
MANIFEST_FILE = ".export-manifest.json"
|
|
183
|
+
SNIPPET_FILE = "context-snippet.md"
|
|
184
|
+
METADATA_FILE = "metadata.json"
|
|
185
|
+
CONTENT_SUFFIX = ".skf-content"
|
|
186
|
+
MANIFEST_OPS = Path(__file__).resolve().parent / "skf-manifest-ops.py"
|
|
187
|
+
IDE_DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "ide-context-files.json"
|
|
188
|
+
FILE_NOT_FOUND = "File not found: "
|
|
59
189
|
|
|
60
190
|
|
|
61
191
|
def read_context_file(file_path):
|
|
62
|
-
"""Read a context file. Returns (content, None) or (None, error).
|
|
192
|
+
"""Read a context file. Returns (content, None) or (None, error).
|
|
193
|
+
|
|
194
|
+
A missing file's error starts with FILE_NOT_FOUND (see is_missing). A
|
|
195
|
+
folder, a file that cannot be read and one that is not UTF-8 text give an
|
|
196
|
+
error of their own, so no action ends in a traceback.
|
|
197
|
+
"""
|
|
63
198
|
try:
|
|
64
199
|
return Path(file_path).read_text(encoding="utf-8"), None
|
|
65
200
|
except FileNotFoundError:
|
|
66
|
-
return None, f"
|
|
201
|
+
return None, f"{FILE_NOT_FOUND}{file_path}"
|
|
202
|
+
except UnicodeDecodeError as e:
|
|
203
|
+
return None, f"{file_path} is not UTF-8 text ({e}): save it as UTF-8, then re-run"
|
|
204
|
+
except OSError as e:
|
|
205
|
+
return None, f"Cannot read {file_path}: {e}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_missing(error):
|
|
209
|
+
"""True for read_context_file's error about a file that does not exist."""
|
|
210
|
+
return error.startswith(FILE_NOT_FOUND)
|
|
67
211
|
|
|
68
212
|
|
|
69
213
|
def find_managed_section(content):
|
|
@@ -71,6 +215,74 @@ def find_managed_section(content):
|
|
|
71
215
|
return MARKER_PATTERN.search(content)
|
|
72
216
|
|
|
73
217
|
|
|
218
|
+
def section_end(content, match):
|
|
219
|
+
"""Where the section `match` found ends, with the END markers left below it.
|
|
220
|
+
|
|
221
|
+
drop-skill and rename-skill once wrote a second marker pair inside the
|
|
222
|
+
section, and every rebuild after that left one more END below it: `match`
|
|
223
|
+
stops at the first END, which closed only the inner BEGIN. So the section
|
|
224
|
+
runs on over each END that follows it with only blank space before it, and
|
|
225
|
+
replace and clear take those in rather than leave them for every later
|
|
226
|
+
write to keep as the user's text. Such an END closes nothing: every BEGIN
|
|
227
|
+
before it is closed already.
|
|
228
|
+
"""
|
|
229
|
+
end = match.end()
|
|
230
|
+
while True:
|
|
231
|
+
closing = TRAILING_END_PATTERN.match(content, end)
|
|
232
|
+
if closing is None:
|
|
233
|
+
return end
|
|
234
|
+
end = closing.end()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def find_unclosed_begin(content):
|
|
238
|
+
"""The first BEGIN marker no END marker closes, as (line, detail), else None.
|
|
239
|
+
|
|
240
|
+
Any `<!-- SKF:BEGIN` counts, by prefix. It must end with `-->` on its own
|
|
241
|
+
line and be closed by a `<!-- SKF:END -->` after it. Text between it and a
|
|
242
|
+
later BEGIN marker, with no END between them, leaves it unclosed: a
|
|
243
|
+
section swap would take that text for SKF's own. BEGIN markers with only
|
|
244
|
+
blank space between them count as one (the second pair drop-skill and
|
|
245
|
+
rename-skill once wrote inside the section). A stray END with no BEGIN
|
|
246
|
+
before it opens nothing, so an insert after it cannot pull the user's text
|
|
247
|
+
into a section.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def line_of(offset):
|
|
251
|
+
return content.count("\n", 0, offset) + 1
|
|
252
|
+
|
|
253
|
+
open_at = open_end = None
|
|
254
|
+
for m in ANY_MARKER_PATTERN.finditer(content):
|
|
255
|
+
if m.group(0) == END_MARKER:
|
|
256
|
+
open_at = None
|
|
257
|
+
continue
|
|
258
|
+
whole = WHOLE_BEGIN_PATTERN.match(content, m.start())
|
|
259
|
+
if not whole:
|
|
260
|
+
return line_of(m.start()), f"<!-- SKF:BEGIN on line {line_of(m.start())} does not end with --> on that line"
|
|
261
|
+
if open_at is not None and content[open_end : m.start()].strip():
|
|
262
|
+
return line_of(open_at), (
|
|
263
|
+
f"<!-- SKF:BEGIN on line {line_of(open_at)} has no matching <!-- SKF:END --> "
|
|
264
|
+
f"before the next <!-- SKF:BEGIN on line {line_of(m.start())}"
|
|
265
|
+
)
|
|
266
|
+
if open_at is None:
|
|
267
|
+
open_at = m.start()
|
|
268
|
+
open_end = whole.end()
|
|
269
|
+
if open_at is None:
|
|
270
|
+
return None
|
|
271
|
+
return line_of(open_at), f"<!-- SKF:BEGIN on line {line_of(open_at)} has no matching <!-- SKF:END --> after it"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _malformed(file_path, problem, *, write):
|
|
275
|
+
"""The error result for a file `find_unclosed_begin` reported."""
|
|
276
|
+
line, detail = problem
|
|
277
|
+
error = (
|
|
278
|
+
f"Malformed SKF markers in {file_path}: {detail}. Fix the markers by hand (restore the "
|
|
279
|
+
"<!-- SKF:END --> line, or remove the stray <!-- SKF:BEGIN), then re-run."
|
|
280
|
+
)
|
|
281
|
+
if write:
|
|
282
|
+
error += " The file was not changed."
|
|
283
|
+
return {"status": "error", "case": "malformed", "begin_line": line, "error": error}
|
|
284
|
+
|
|
285
|
+
|
|
74
286
|
def _atomic_write(file_path, text):
|
|
75
287
|
"""Stage `text` into <file>.skf-tmp, fsync, then os.replace into place.
|
|
76
288
|
|
|
@@ -130,40 +342,48 @@ def _write_and_verify(file_path, updated, *, expect_section):
|
|
|
130
342
|
|
|
131
343
|
|
|
132
344
|
def cmd_check(file_path):
|
|
133
|
-
"""
|
|
345
|
+
"""Report the managed section and the write that fits the file (`case`)."""
|
|
134
346
|
content, err = read_context_file(file_path)
|
|
347
|
+
if err and is_missing(err):
|
|
348
|
+
return {"status": "ok", "exists": False, "case": "create", "has_managed_section": False, "markers_valid": True}
|
|
135
349
|
if err:
|
|
136
|
-
return {
|
|
350
|
+
return {
|
|
351
|
+
"status": "error",
|
|
352
|
+
"case": "unreadable",
|
|
353
|
+
"error": err,
|
|
354
|
+
"exists": True,
|
|
355
|
+
"has_managed_section": False,
|
|
356
|
+
"markers_valid": False,
|
|
357
|
+
}
|
|
137
358
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
359
|
+
problem = find_unclosed_begin(content)
|
|
360
|
+
if problem:
|
|
361
|
+
result = _malformed(file_path, problem, write=False)
|
|
362
|
+
result.update(exists=True, has_managed_section=False, markers_valid=False, error_detail=problem[1])
|
|
363
|
+
return result
|
|
141
364
|
|
|
365
|
+
match = find_managed_section(content)
|
|
142
366
|
if match:
|
|
143
367
|
section_content = match.group(2)
|
|
144
368
|
return {
|
|
145
369
|
"status": "ok",
|
|
370
|
+
"exists": True,
|
|
371
|
+
"case": "regenerate",
|
|
146
372
|
"has_managed_section": True,
|
|
147
373
|
"markers_valid": True,
|
|
148
374
|
"section_length": len(section_content.strip()),
|
|
149
375
|
"section_line_count": len(section_content.strip().split("\n")) if section_content.strip() else 0,
|
|
150
376
|
}
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
"status": "ok",
|
|
154
|
-
"has_managed_section": False,
|
|
155
|
-
"markers_valid": False,
|
|
156
|
-
"error_detail": "Found <!-- SKF:BEGIN but no matching <!-- SKF:END -->",
|
|
157
|
-
}
|
|
158
|
-
elif not has_begin and has_end:
|
|
377
|
+
if END_MARKER in content:
|
|
159
378
|
return {
|
|
160
379
|
"status": "ok",
|
|
380
|
+
"exists": True,
|
|
381
|
+
"case": "append",
|
|
161
382
|
"has_managed_section": False,
|
|
162
383
|
"markers_valid": False,
|
|
163
384
|
"error_detail": "Found <!-- SKF:END --> but no matching <!-- SKF:BEGIN",
|
|
164
385
|
}
|
|
165
|
-
|
|
166
|
-
return {"status": "ok", "has_managed_section": False, "markers_valid": True}
|
|
386
|
+
return {"status": "ok", "exists": True, "case": "append", "has_managed_section": False, "markers_valid": True}
|
|
167
387
|
|
|
168
388
|
|
|
169
389
|
def cmd_read(file_path):
|
|
@@ -179,12 +399,35 @@ def cmd_read(file_path):
|
|
|
179
399
|
return {"status": "ok", "has_managed_section": True, "content": match.group(2)}
|
|
180
400
|
|
|
181
401
|
|
|
402
|
+
def _marker_in_body_error(new_content):
|
|
403
|
+
"""An error message when `new_content` holds an SKF marker, else None.
|
|
404
|
+
|
|
405
|
+
replace and insert write both markers around the body themselves, so a body
|
|
406
|
+
that carries `<!-- SKF:BEGIN …` or `<!-- SKF:END -->` (the whole managed
|
|
407
|
+
section instead of what goes between its markers) would leave a second
|
|
408
|
+
marker pair nested inside the section.
|
|
409
|
+
"""
|
|
410
|
+
if MARKER_IN_BODY_PATTERN.search(new_content) is None:
|
|
411
|
+
return None
|
|
412
|
+
return (
|
|
413
|
+
"content holds an SKF marker (<!-- SKF:BEGIN or <!-- SKF:END): pass only "
|
|
414
|
+
"the body that goes between the markers; this helper writes both markers "
|
|
415
|
+
"and the updated: date itself. The file was not changed."
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
182
419
|
def cmd_replace(file_path, new_content):
|
|
183
420
|
"""Replace managed section content between markers."""
|
|
421
|
+
marker_err = _marker_in_body_error(new_content)
|
|
422
|
+
if marker_err:
|
|
423
|
+
return {"status": "error", "error": marker_err}
|
|
184
424
|
content, err = read_context_file(file_path)
|
|
185
425
|
if err:
|
|
186
426
|
return {"status": "error", "error": err}
|
|
187
427
|
|
|
428
|
+
problem = find_unclosed_begin(content)
|
|
429
|
+
if problem:
|
|
430
|
+
return _malformed(file_path, problem, write=True)
|
|
188
431
|
match = find_managed_section(content)
|
|
189
432
|
if not match:
|
|
190
433
|
return {"status": "error", "error": "No managed section found. Use 'insert' to create one."}
|
|
@@ -192,7 +435,7 @@ def cmd_replace(file_path, new_content):
|
|
|
192
435
|
# Replace content between markers with fresh timestamp
|
|
193
436
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
194
437
|
new_section = f"<!-- SKF:BEGIN updated:{today} -->\n{new_content}\n{END_MARKER}"
|
|
195
|
-
updated = content[: match.start()] + new_section + content[match
|
|
438
|
+
updated = content[: match.start()] + new_section + content[section_end(content, match) :]
|
|
196
439
|
|
|
197
440
|
verify_err = _write_and_verify(file_path, updated, expect_section=True)
|
|
198
441
|
if verify_err:
|
|
@@ -206,13 +449,16 @@ def cmd_clear(file_path):
|
|
|
206
449
|
if err:
|
|
207
450
|
return {"status": "error", "error": err}
|
|
208
451
|
|
|
452
|
+
problem = find_unclosed_begin(content)
|
|
453
|
+
if problem:
|
|
454
|
+
return _malformed(file_path, problem, write=True)
|
|
209
455
|
match = find_managed_section(content)
|
|
210
456
|
if not match:
|
|
211
457
|
return {"status": "ok", "action": "no_change", "reason": "No managed section found"}
|
|
212
458
|
|
|
213
459
|
# Remove the entire marker block, plus any surrounding blank lines
|
|
214
460
|
before = content[: match.start()].rstrip("\n")
|
|
215
|
-
after = content[match
|
|
461
|
+
after = content[section_end(content, match) :].lstrip("\n")
|
|
216
462
|
updated = before + ("\n\n" if before and after else "") + after
|
|
217
463
|
|
|
218
464
|
verify_err = _write_and_verify(file_path, updated, expect_section=False)
|
|
@@ -223,14 +469,20 @@ def cmd_clear(file_path):
|
|
|
223
469
|
|
|
224
470
|
def cmd_insert(file_path, new_content):
|
|
225
471
|
"""Insert managed section at end of file if not present."""
|
|
472
|
+
marker_err = _marker_in_body_error(new_content)
|
|
473
|
+
if marker_err:
|
|
474
|
+
return {"status": "error", "error": marker_err}
|
|
226
475
|
content, err = read_context_file(file_path)
|
|
227
476
|
if err:
|
|
228
|
-
#
|
|
229
|
-
if
|
|
477
|
+
# A missing file is created; one that cannot be read is left alone.
|
|
478
|
+
if is_missing(err):
|
|
230
479
|
content = ""
|
|
231
480
|
else:
|
|
232
481
|
return {"status": "error", "error": err}
|
|
233
482
|
|
|
483
|
+
problem = find_unclosed_begin(content)
|
|
484
|
+
if problem:
|
|
485
|
+
return _malformed(file_path, problem, write=True)
|
|
234
486
|
match = find_managed_section(content)
|
|
235
487
|
if match:
|
|
236
488
|
return {"status": "error", "error": "Managed section already exists. Use 'replace' instead."}
|
|
@@ -282,27 +534,35 @@ def parse_managed_rows(body):
|
|
|
282
534
|
return rows
|
|
283
535
|
|
|
284
536
|
|
|
285
|
-
def
|
|
286
|
-
"""
|
|
537
|
+
def _scan_rows(context_files, known_names):
|
|
538
|
+
"""Rows of skills outside `known_names` across `context_files`, the malformed files and the unreadable ones.
|
|
287
539
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
540
|
+
Reads each file that exists and has a managed section. A file whose
|
|
541
|
+
markers are malformed is skipped and returned in the second list: its
|
|
542
|
+
section cannot be told apart from the user's own text. A file that cannot
|
|
543
|
+
be read, or is not UTF-8 text, is skipped and its error returned in the
|
|
544
|
+
third list: its rows are unknown, so a caller stops rather than drop them.
|
|
545
|
+
Rows are keyed by (skill_name, version): the first-seen snippet_text wins,
|
|
546
|
+
and each file the row appears in is added to its source_files.
|
|
292
547
|
Deterministic: rows sorted by (skill_name, version), source_files sorted.
|
|
293
548
|
"""
|
|
294
|
-
exported = {s.strip() for s in exported_skills.split(",") if s.strip()}
|
|
295
549
|
orphans = {} # (name, version) -> {skill_name, version, snippet_text, source_files:set}
|
|
550
|
+
malformed, unreadable = [], []
|
|
296
551
|
for cf in context_files:
|
|
297
552
|
content, err = read_context_file(cf)
|
|
298
553
|
if err:
|
|
299
|
-
|
|
554
|
+
if not is_missing(err):
|
|
555
|
+
unreadable.append(err)
|
|
556
|
+
continue # a missing file has no rows
|
|
557
|
+
if find_unclosed_begin(content):
|
|
558
|
+
malformed.append(cf)
|
|
559
|
+
continue
|
|
300
560
|
match = find_managed_section(content)
|
|
301
561
|
if not match:
|
|
302
|
-
continue # no managed section
|
|
562
|
+
continue # no managed section, no rows
|
|
303
563
|
for row in parse_managed_rows(match.group(2)):
|
|
304
|
-
if row["skill_name"] in
|
|
305
|
-
continue
|
|
564
|
+
if row["skill_name"] in known_names:
|
|
565
|
+
continue
|
|
306
566
|
key = (row["skill_name"], row["version"])
|
|
307
567
|
if key in orphans:
|
|
308
568
|
orphans[key]["source_files"].add(cf) # keep first-seen snippet_text
|
|
@@ -322,55 +582,553 @@ def cmd_orphan_detect(context_files, exported_skills):
|
|
|
322
582
|
}
|
|
323
583
|
for _, v in sorted(orphans.items())
|
|
324
584
|
]
|
|
325
|
-
return
|
|
585
|
+
return orphan_rows, malformed, unreadable
|
|
326
586
|
|
|
327
587
|
|
|
328
|
-
def
|
|
588
|
+
def cmd_orphan_detect(context_files, exported_skills):
|
|
589
|
+
"""Detect managed-section rows absent from the exported skill set.
|
|
590
|
+
|
|
591
|
+
Scans every context file that exists and has a managed section, parses its
|
|
592
|
+
skill rows, drops those whose skill_name is in `exported_skills`, and
|
|
593
|
+
accumulates the survivors keyed by (skill_name, version). The first-seen
|
|
594
|
+
snippet_text wins; each file the row appears in is added to source_files.
|
|
595
|
+
Deterministic: rows sorted by (skill_name, version), source_files sorted.
|
|
596
|
+
A file with malformed markers is skipped and listed in malformed_files. A
|
|
597
|
+
file that cannot be read, or is not UTF-8 text, is an error.
|
|
598
|
+
"""
|
|
599
|
+
exported = {s.strip() for s in exported_skills.split(",") if s.strip()}
|
|
600
|
+
orphan_rows, malformed, unreadable = _scan_rows(context_files, exported)
|
|
601
|
+
if unreadable:
|
|
602
|
+
return {"status": "error", "error": "; ".join(unreadable)}
|
|
603
|
+
return {"status": "ok", "orphan_managed_rows": orphan_rows, "malformed_files": malformed}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _holds_skill(project_root, prefix, name):
|
|
607
|
+
"""True when the folder `prefix` names under `project_root` holds `name`'s
|
|
608
|
+
SKILL.md: flat, as `npx skills add` installs it, or through the `active`
|
|
609
|
+
link of SKF's versioned layout."""
|
|
610
|
+
base = Path(project_root, prefix, name)
|
|
611
|
+
return (base / "SKILL.md").is_file() or (base / "active" / name / "SKILL.md").is_file()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def cmd_root_probe(snippet_files, reference_root, project_root=None):
|
|
329
615
|
"""Observe snippet root prefixes and flag mismatch against a reference.
|
|
330
616
|
|
|
331
617
|
Reads the first line of each snippet that exists, parses its `root:` value,
|
|
332
618
|
strips the trailing `{skill-name}/` to recover the prefix, and collects the
|
|
333
|
-
unique prefixes.
|
|
334
|
-
|
|
619
|
+
unique prefixes. A root in the draft form (DRAFT_ROOT_FORMS) is no
|
|
620
|
+
evidence of where an export pointed the skill: the snippet is left out and
|
|
621
|
+
its skill listed in `draft_roots`. `mismatch` is True when any observed
|
|
622
|
+
prefix differs from `reference_root`, and `mismatched_skills` names the
|
|
623
|
+
skills whose prefix does. Deterministic: every list sorted.
|
|
624
|
+
|
|
625
|
+
With `project_root`, `disk_root` says which folder on disk holds the
|
|
626
|
+
mismatched skills: `reference_root` when it holds every one, else the one
|
|
627
|
+
observed prefix when only one was observed and it holds every one, else
|
|
628
|
+
None (also when nothing mismatched).
|
|
335
629
|
"""
|
|
336
630
|
observed = set()
|
|
631
|
+
drafts = set()
|
|
632
|
+
mismatched = [] # (skill name or None, prefix)
|
|
337
633
|
for sf in snippet_files:
|
|
338
634
|
content, err = read_context_file(sf)
|
|
339
635
|
if err:
|
|
340
|
-
continue # missing snippet
|
|
636
|
+
continue # a missing or unreadable snippet: skip
|
|
341
637
|
first_line = content.split("\n", 1)[0]
|
|
342
638
|
header = ROW_HEADER_PATTERN.match(first_line)
|
|
343
639
|
root_match = SNIPPET_ROOT_PATTERN.search(first_line)
|
|
344
640
|
if not root_match:
|
|
345
|
-
continue # no root: field
|
|
641
|
+
continue # no root: field, skip
|
|
346
642
|
root_value = root_match.group("root")
|
|
643
|
+
name = header.group("name") if header else None
|
|
644
|
+
if name and root_value in {form.format(name=name) for form in DRAFT_ROOT_FORMS}:
|
|
645
|
+
drafts.add(name)
|
|
646
|
+
continue
|
|
347
647
|
prefix = root_value
|
|
348
|
-
if
|
|
349
|
-
|
|
350
|
-
if root_value.endswith(suffix):
|
|
351
|
-
prefix = root_value[: -len(suffix)]
|
|
648
|
+
if name and root_value.endswith(name + "/"):
|
|
649
|
+
prefix = root_value[: -len(name + "/")]
|
|
352
650
|
observed.add(prefix)
|
|
651
|
+
if prefix != reference_root:
|
|
652
|
+
mismatched.append((name, prefix))
|
|
353
653
|
observed_prefixes = sorted(observed)
|
|
354
|
-
|
|
654
|
+
result = {
|
|
355
655
|
"status": "ok",
|
|
356
656
|
"reference_root": reference_root,
|
|
357
657
|
"observed_prefixes": observed_prefixes,
|
|
358
|
-
"mismatch":
|
|
658
|
+
"mismatch": bool(mismatched),
|
|
659
|
+
"mismatched_skills": sorted({name for name, _ in mismatched if name}),
|
|
660
|
+
"draft_roots": sorted(drafts),
|
|
359
661
|
}
|
|
662
|
+
if project_root is not None:
|
|
663
|
+
def held_by(prefix):
|
|
664
|
+
return all(name and _holds_skill(project_root, prefix, name) for name, _ in mismatched)
|
|
665
|
+
|
|
666
|
+
disk_root = None
|
|
667
|
+
if mismatched and held_by(reference_root):
|
|
668
|
+
disk_root = reference_root
|
|
669
|
+
elif mismatched and len(observed_prefixes) == 1 and held_by(observed_prefixes[0]):
|
|
670
|
+
disk_root = observed_prefixes[0]
|
|
671
|
+
result["disk_root"] = disk_root
|
|
672
|
+
return result
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# ---------------------------------------------------------------------------
|
|
676
|
+
# assemble: the managed-section body, built from the manifest and the snippets
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def is_plain_name(value):
|
|
681
|
+
"""True for a skill name or version that is one folder name and nothing else."""
|
|
682
|
+
return bool(value) and value not in (".", "..") and not any(c in value for c in "/\\:\0")
|
|
683
|
+
|
|
360
684
|
|
|
685
|
+
def _as_prefix(value):
|
|
686
|
+
"""A skill root prefix, with the trailing `/` the `root:` field needs."""
|
|
687
|
+
value = (value or "").strip()
|
|
688
|
+
return value if not value or value.endswith("/") else value + "/"
|
|
361
689
|
|
|
362
|
-
def _run_query_action(argv):
|
|
363
|
-
"""Dispatch the read-only query actions (orphan-detect / root-probe).
|
|
364
690
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
691
|
+
def _read_manifest(skills_folder):
|
|
692
|
+
"""(exports, None) from the export manifest, or (None, error).
|
|
693
|
+
|
|
694
|
+
Reads through skf-manifest-ops.py, which installs beside this script, so
|
|
695
|
+
a v1 manifest or a legacy `platforms` field reads the same here as in
|
|
696
|
+
every other manifest step. A missing manifest has no exports.
|
|
368
697
|
"""
|
|
369
|
-
|
|
698
|
+
try:
|
|
699
|
+
spec = importlib.util.spec_from_file_location("skf_manifest_ops", MANIFEST_OPS)
|
|
700
|
+
ops = importlib.util.module_from_spec(spec)
|
|
701
|
+
spec.loader.exec_module(ops)
|
|
702
|
+
except (ImportError, OSError, AttributeError) as e:
|
|
703
|
+
return None, f"cannot load {MANIFEST_OPS.name} beside this script ({e}): re-install SKF"
|
|
704
|
+
manifest_path = Path(skills_folder) / MANIFEST_FILE
|
|
705
|
+
try:
|
|
706
|
+
data, err = ops.read_manifest(manifest_path)
|
|
707
|
+
except (OSError, ValueError, AttributeError, TypeError) as e:
|
|
708
|
+
return None, f"cannot read {manifest_path}: {e}"
|
|
709
|
+
if err:
|
|
710
|
+
return None, f"cannot read {manifest_path}: {err}"
|
|
711
|
+
exports = data.get("exports", {})
|
|
712
|
+
if not isinstance(exports, dict):
|
|
713
|
+
return None, f"cannot read {manifest_path}: `exports` is not an object"
|
|
714
|
+
return exports, None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _active_version(name, entry):
|
|
718
|
+
"""(active_version, None) for a manifest entry the section can use, else (active_version, reason)."""
|
|
719
|
+
if not isinstance(entry, dict):
|
|
720
|
+
return None, "the manifest entry is not an object"
|
|
721
|
+
active = entry.get("active_version")
|
|
722
|
+
if not isinstance(active, str) or not active:
|
|
723
|
+
return None, "the manifest entry has no active_version"
|
|
724
|
+
versions = entry.get("versions")
|
|
725
|
+
if not isinstance(versions, dict) or not isinstance(versions.get(active), dict):
|
|
726
|
+
return active, f"active_version {active} has no entry under versions"
|
|
727
|
+
if not is_plain_name(name) or not is_plain_name(active):
|
|
728
|
+
return active, "the skill name or its active_version is not a plain folder name"
|
|
729
|
+
return active, None
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _find_snippet(skills_folder, name, version, given):
|
|
733
|
+
"""(snippet path, source, package folder, paths tried) for one skill.
|
|
734
|
+
|
|
735
|
+
The package is the versioned one, else the `active` link's, else the flat
|
|
736
|
+
layout's: the first that holds a context-snippet.md. A `given` snippet
|
|
737
|
+
replaces that search, and its package is the first that holds a
|
|
738
|
+
metadata.json. The path is None when no snippet was found.
|
|
739
|
+
"""
|
|
740
|
+
group = Path(skills_folder) / name
|
|
741
|
+
packages = [("versioned", group / version / name), ("active", group / "active" / name), ("flat", group)]
|
|
742
|
+
if given is not None:
|
|
743
|
+
if not Path(given).is_file():
|
|
744
|
+
return None, None, None, [str(given)]
|
|
745
|
+
package = next((p for _, p in packages if (p / METADATA_FILE).is_file()), None)
|
|
746
|
+
return Path(given), "given", package, [str(given)]
|
|
747
|
+
tried = []
|
|
748
|
+
for source, package in packages:
|
|
749
|
+
snippet = package / SNIPPET_FILE
|
|
750
|
+
tried.append(str(snippet))
|
|
751
|
+
if snippet.is_file():
|
|
752
|
+
return snippet, source, package, tried
|
|
753
|
+
return None, None, None, tried
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _row_shape(row_text):
|
|
757
|
+
"""`stack` for a row with a `|stack:` line (the stack snippet template), else `single`."""
|
|
758
|
+
return "stack" if STACK_LINE_PATTERN.search(row_text) else "single"
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _skill_type(package, row_text):
|
|
762
|
+
"""(skill_type, warning or None): metadata.json's skill_type, else the row's shape.
|
|
763
|
+
|
|
764
|
+
`individual` is a single skill, as skf-skill-inventory.py reads it too.
|
|
765
|
+
"""
|
|
766
|
+
shape = _row_shape(row_text)
|
|
767
|
+
if package is None:
|
|
768
|
+
return shape, f"no {METADATA_FILE} found, counted as {shape} from its snippet"
|
|
769
|
+
try:
|
|
770
|
+
meta = json.loads((package / METADATA_FILE).read_text(encoding="utf-8"))
|
|
771
|
+
except (OSError, ValueError) as e:
|
|
772
|
+
return shape, f"cannot read {package / METADATA_FILE} ({e}), counted as {shape} from its snippet"
|
|
773
|
+
skill_type = meta.get("skill_type") if isinstance(meta, dict) else None
|
|
774
|
+
if skill_type in ("single", "individual"):
|
|
775
|
+
return "single", None
|
|
776
|
+
if skill_type == "stack":
|
|
777
|
+
return "stack", None
|
|
778
|
+
return shape, (
|
|
779
|
+
f"{package / METADATA_FILE} has no skill_type single, individual or stack, "
|
|
780
|
+
f"counted as {shape} from its snippet"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _snippet_row(text, name, prefix):
|
|
785
|
+
"""The row a snippet adds to the section, or (None, reason).
|
|
786
|
+
|
|
787
|
+
Returns ({text, version, root_rewritten, has_root}, None). The row is the
|
|
788
|
+
snippet without its blank edge lines, with `|` before its header line (the
|
|
789
|
+
format puts one there; a snippet that already has it keeps one) and its
|
|
790
|
+
`root:` value set to `prefix` + name + `/`. A snippet whose first line is
|
|
791
|
+
not the `[name vX]` row header of this skill, or that holds an SKF marker,
|
|
792
|
+
is refused: the section could not be parsed back into the same rows.
|
|
793
|
+
"""
|
|
794
|
+
lines = text.split("\n")
|
|
795
|
+
while lines and not lines[0].strip():
|
|
796
|
+
lines.pop(0)
|
|
797
|
+
while lines and not lines[-1].strip():
|
|
798
|
+
lines.pop()
|
|
799
|
+
if not lines:
|
|
800
|
+
return None, "the snippet is empty"
|
|
801
|
+
if MARKER_IN_BODY_PATTERN.search(text):
|
|
802
|
+
return None, "the snippet holds an SKF marker (<!-- SKF:BEGIN or <!-- SKF:END)"
|
|
803
|
+
first = lines[0][1:] if lines[0].startswith("|") else lines[0]
|
|
804
|
+
header = ROW_HEADER_PATTERN.match(first)
|
|
805
|
+
if not first.startswith("[") or not header:
|
|
806
|
+
return None, "its first line is not a [skill-name vX.Y.Z] row header"
|
|
807
|
+
if header.group("name") != name:
|
|
808
|
+
return None, f"its header names {header.group('name')}, not {name}"
|
|
809
|
+
root = SNIPPET_ROOT_PATTERN.search(first)
|
|
810
|
+
rewritten = False
|
|
811
|
+
if root:
|
|
812
|
+
new_root = prefix + name + "/"
|
|
813
|
+
rewritten = root.group("root") != new_root
|
|
814
|
+
first = first[: root.start("root")] + new_root + first[root.end("root") :]
|
|
815
|
+
lines[0] = "|" + first
|
|
816
|
+
row = {"text": "\n".join(lines), "version": header.group("version"), "root_rewritten": rewritten, "has_root": root is not None}
|
|
817
|
+
return row, None
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def cmd_assemble(
|
|
821
|
+
context_file,
|
|
822
|
+
skills_folder,
|
|
823
|
+
skill_root,
|
|
824
|
+
*,
|
|
825
|
+
override=None,
|
|
826
|
+
includes=(),
|
|
827
|
+
snippets=None,
|
|
828
|
+
snippet_dir=None,
|
|
829
|
+
renamed=None,
|
|
830
|
+
dropped=(),
|
|
831
|
+
orphan_sources=(),
|
|
832
|
+
orphans="keep",
|
|
833
|
+
out=None,
|
|
834
|
+
):
|
|
835
|
+
"""Build the managed-section body for `context_file` and stage it for replace/insert.
|
|
836
|
+
|
|
837
|
+
`includes` is a list of (name, version), `snippets` maps a skill name to
|
|
838
|
+
the snippet file to use, `snippet_dir` is a folder of staged drafts (a
|
|
839
|
+
`snippets` entry wins over it), `renamed` is (old, new) and `dropped` a
|
|
840
|
+
list of names. The body goes to `out`, else to
|
|
841
|
+
<context_file>.skf-content. See the module docstring for the rules.
|
|
842
|
+
"""
|
|
843
|
+
snippets = snippets or {}
|
|
844
|
+
prefix = _as_prefix(override) or _as_prefix(skill_root)
|
|
845
|
+
if not prefix:
|
|
846
|
+
return {"status": "error", "error": "the skill root is empty: pass --skill-root (or --skill-root-override)"}
|
|
847
|
+
content_file = out or context_file + CONTENT_SUFFIX
|
|
848
|
+
if out:
|
|
849
|
+
read_here = {os.path.normcase(os.path.realpath(cf)) for cf in [*orphan_sources, context_file]}
|
|
850
|
+
if os.path.normcase(os.path.realpath(out)) in read_here:
|
|
851
|
+
return {
|
|
852
|
+
"status": "error",
|
|
853
|
+
"error": f"--out {out} is a context file this call reads: pass a path of its own. Nothing was written.",
|
|
854
|
+
}
|
|
855
|
+
if not Path(skills_folder).is_dir():
|
|
856
|
+
return {"status": "error", "error": f"the skills folder {skills_folder} does not exist. Nothing was written."}
|
|
857
|
+
exports, err = _read_manifest(skills_folder)
|
|
858
|
+
if err:
|
|
859
|
+
return {"status": "error", "error": f"{err}. Nothing was written."}
|
|
860
|
+
include_versions = dict(includes)
|
|
861
|
+
|
|
862
|
+
wanted = {} # name -> active_version
|
|
863
|
+
skipped_deprecated, skipped_integrity = [], []
|
|
864
|
+
for name in sorted(exports):
|
|
865
|
+
if name in include_versions:
|
|
866
|
+
continue # --include sets this skill's version
|
|
867
|
+
active, reason = _active_version(name, exports[name])
|
|
868
|
+
if reason:
|
|
869
|
+
skipped_integrity.append({"skill_name": name, "active_version": active, "reason": reason})
|
|
870
|
+
elif exports[name]["versions"][active].get("status") == "deprecated":
|
|
871
|
+
skipped_deprecated.append({"skill_name": name, "version": active})
|
|
872
|
+
else:
|
|
873
|
+
wanted[name] = active
|
|
874
|
+
wanted.update(include_versions)
|
|
875
|
+
|
|
876
|
+
entries = [] # (skill_name, version, row text, skill_type)
|
|
877
|
+
included, skipped_missing, skipped_malformed, warnings = [], [], [], []
|
|
878
|
+
for name in sorted(set(snippets) - set(wanted)):
|
|
879
|
+
warnings.append(f"--snippet {name}: not one of the section's skills, so its file was not read")
|
|
880
|
+
for name in sorted(wanted):
|
|
881
|
+
version = wanted[name]
|
|
882
|
+
given = snippets.get(name)
|
|
883
|
+
if given is None and snippet_dir and (Path(snippet_dir) / name / SNIPPET_FILE).is_file():
|
|
884
|
+
given = str(Path(snippet_dir) / name / SNIPPET_FILE)
|
|
885
|
+
path, source, package, tried = _find_snippet(skills_folder, name, version, given)
|
|
886
|
+
if path is None:
|
|
887
|
+
skipped_missing.append({"skill_name": name, "version": version, "tried": tried})
|
|
888
|
+
continue
|
|
889
|
+
try:
|
|
890
|
+
text = path.read_text(encoding="utf-8")
|
|
891
|
+
except (OSError, ValueError) as e:
|
|
892
|
+
skipped_malformed.append({"skill_name": name, "version": version, "snippet_path": str(path), "reason": f"cannot read it: {e}"})
|
|
893
|
+
continue
|
|
894
|
+
row, reason = _snippet_row(text, name, prefix)
|
|
895
|
+
if reason:
|
|
896
|
+
skipped_malformed.append({"skill_name": name, "version": version, "snippet_path": str(path), "reason": reason})
|
|
897
|
+
continue
|
|
898
|
+
if not row["has_root"]:
|
|
899
|
+
warnings.append(f"{name}: {path} has no root: field, so its row keeps no root path")
|
|
900
|
+
skill_type, warning = _skill_type(package, row["text"])
|
|
901
|
+
if warning:
|
|
902
|
+
warnings.append(f"{name}: {warning}")
|
|
903
|
+
entries.append((name, row["version"], row["text"], skill_type))
|
|
904
|
+
included.append(
|
|
905
|
+
{
|
|
906
|
+
"skill_name": name,
|
|
907
|
+
"version": row["version"],
|
|
908
|
+
"active_version": version,
|
|
909
|
+
"skill_type": skill_type,
|
|
910
|
+
"snippet_path": str(path),
|
|
911
|
+
"snippet_source": source,
|
|
912
|
+
"root_rewritten": row["root_rewritten"],
|
|
913
|
+
}
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Orphans: rows of skills the manifest does not know, from every source file.
|
|
917
|
+
known = set(exports) | set(wanted) | set(dropped)
|
|
918
|
+
if renamed:
|
|
919
|
+
known.add(renamed[0])
|
|
920
|
+
if renamed[1] not in wanted:
|
|
921
|
+
warnings.append(f"--renamed: {renamed[1]} is not in the manifest, so the section has no row for it")
|
|
922
|
+
sources, seen = [], set()
|
|
923
|
+
for cf in [*orphan_sources, context_file]:
|
|
924
|
+
key = os.path.normcase(os.path.abspath(cf))
|
|
925
|
+
if key not in seen:
|
|
926
|
+
seen.add(key)
|
|
927
|
+
sources.append(cf)
|
|
928
|
+
orphan_rows, malformed_files, unreadable = _scan_rows(sources, known)
|
|
929
|
+
if unreadable:
|
|
930
|
+
return {"status": "error", "error": f"{'; '.join(unreadable)}. Nothing was written."}
|
|
931
|
+
for row in orphan_rows:
|
|
932
|
+
row["skill_type"] = _row_shape(row["snippet_text"])
|
|
933
|
+
if orphans == "keep":
|
|
934
|
+
# A row that holds a marker variant would make replace and insert refuse the whole body.
|
|
935
|
+
marked = [r for r in orphan_rows if MARKER_IN_BODY_PATTERN.search(r["snippet_text"])]
|
|
936
|
+
if marked:
|
|
937
|
+
where = "; ".join(f"{r['skill_name']} v{r['version']} in {', '.join(r['source_files'])}" for r in marked)
|
|
938
|
+
return {
|
|
939
|
+
"status": "error",
|
|
940
|
+
"error": f"an orphan row to keep holds an SKF marker (<!-- SKF:BEGIN or <!-- SKF:END): {where}. "
|
|
941
|
+
"Fix it by hand, then re-run. Nothing was written.",
|
|
942
|
+
}
|
|
943
|
+
entries += [(r["skill_name"], r["version"], r["snippet_text"], r["skill_type"]) for r in orphan_rows]
|
|
944
|
+
|
|
945
|
+
entries.sort(key=lambda e: (e[0], e[1]))
|
|
946
|
+
n_single = sum(1 for e in entries if e[3] == "single")
|
|
947
|
+
n_stack = len(entries) - n_single
|
|
948
|
+
lines = [SECTION_HEADER.format(n=n_single, m=n_stack), *SECTION_PREAMBLE]
|
|
949
|
+
for entry in entries:
|
|
950
|
+
lines += ["|", entry[2]]
|
|
951
|
+
body = "\n".join(lines)
|
|
952
|
+
|
|
953
|
+
try:
|
|
954
|
+
Path(content_file).parent.mkdir(parents=True, exist_ok=True)
|
|
955
|
+
_atomic_write(content_file, body)
|
|
956
|
+
except OSError as e:
|
|
957
|
+
return {"status": "error", "error": f"cannot write {content_file}: {e}"}
|
|
958
|
+
return {
|
|
959
|
+
"status": "ok",
|
|
960
|
+
"context_file": context_file,
|
|
961
|
+
"content_file": content_file,
|
|
962
|
+
"skill_root": skill_root,
|
|
963
|
+
"effective_root": prefix,
|
|
964
|
+
"n_single": n_single,
|
|
965
|
+
"n_stack": n_stack,
|
|
966
|
+
"included": included,
|
|
967
|
+
"orphans": orphans,
|
|
968
|
+
"orphan_rows": orphan_rows,
|
|
969
|
+
"skipped_deprecated": skipped_deprecated,
|
|
970
|
+
"skipped_integrity": skipped_integrity,
|
|
971
|
+
"skipped_missing_snippet": skipped_missing,
|
|
972
|
+
"skipped_malformed_snippet": skipped_malformed,
|
|
973
|
+
"malformed_context_files": malformed_files,
|
|
974
|
+
"warnings": warnings,
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
# ---------------------------------------------------------------------------
|
|
979
|
+
# resolve-targets: config.yaml `ides` to the context files to write
|
|
980
|
+
# ---------------------------------------------------------------------------
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def load_ide_mapping():
|
|
984
|
+
"""(ides, unknown_ide) from shared/data/ide-context-files.json. Raises OSError or ValueError."""
|
|
985
|
+
data = json.loads(IDE_DATA_FILE.read_text(encoding="utf-8"))
|
|
986
|
+
|
|
987
|
+
def usable(entry):
|
|
988
|
+
return isinstance(entry, dict) and all(isinstance(entry.get(k), str) and entry[k] for k in ("context_file", "skill_root"))
|
|
989
|
+
|
|
990
|
+
ides = data.get("ides") if isinstance(data, dict) else None
|
|
991
|
+
unknown = data.get("unknown_ide") if isinstance(data, dict) else None
|
|
992
|
+
if not isinstance(ides, dict) or not usable(unknown) or not all(usable(e) for e in ides.values()):
|
|
993
|
+
raise ValueError("it needs an `ides` object and an `unknown_ide` entry, each with context_file and skill_root")
|
|
994
|
+
return ides, unknown
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cmd_resolve_targets(ides_csv, context_file=None):
|
|
998
|
+
"""The context files to write for config.yaml's `ides`, one target per file.
|
|
999
|
+
|
|
1000
|
+
Unknown IDEs resolve to the mapping's `unknown_ide` with a warning, and an
|
|
1001
|
+
empty list to it with a note. `context_file` keeps that one file, with the
|
|
1002
|
+
skill root of the first configured IDE that maps to it (else the root the
|
|
1003
|
+
mapping gives that file: its first IDE's, or unknown_ide's for AGENTS.md).
|
|
1004
|
+
"""
|
|
1005
|
+
try:
|
|
1006
|
+
known_ides, unknown = load_ide_mapping()
|
|
1007
|
+
except (OSError, ValueError) as e:
|
|
1008
|
+
return {"status": "error", "error": f"cannot read {IDE_DATA_FILE.name}: {e}"}
|
|
1009
|
+
|
|
1010
|
+
ides = []
|
|
1011
|
+
for raw in (ides_csv or "").split(","):
|
|
1012
|
+
ide = raw.strip()
|
|
1013
|
+
if ide and ide not in ides:
|
|
1014
|
+
ides.append(ide)
|
|
1015
|
+
warnings, notes, unknown_ides = [], [], []
|
|
1016
|
+
mapped = [] # (ide, context_file, skill_root)
|
|
1017
|
+
for ide in ides:
|
|
1018
|
+
entry = known_ides.get(ide)
|
|
1019
|
+
if entry is None:
|
|
1020
|
+
unknown_ides.append(ide)
|
|
1021
|
+
warnings.append(
|
|
1022
|
+
f"Unknown IDE '{ide}' in config.yaml: defaulting to {unknown['context_file']} with `{unknown['skill_root']}`"
|
|
1023
|
+
)
|
|
1024
|
+
entry = unknown
|
|
1025
|
+
mapped.append((ide, entry["context_file"], entry["skill_root"]))
|
|
1026
|
+
|
|
1027
|
+
known_files = []
|
|
1028
|
+
for entry in [*known_ides.values(), unknown]:
|
|
1029
|
+
if entry["context_file"] not in known_files:
|
|
1030
|
+
known_files.append(entry["context_file"])
|
|
1031
|
+
|
|
1032
|
+
if context_file:
|
|
1033
|
+
if context_file not in known_files:
|
|
1034
|
+
return {
|
|
1035
|
+
"status": "error",
|
|
1036
|
+
"error": f"unknown context file '{context_file}': expected one of {', '.join(known_files)}",
|
|
1037
|
+
}
|
|
1038
|
+
matching = [(ide, root) for ide, cf, root in mapped if cf == context_file]
|
|
1039
|
+
if matching:
|
|
1040
|
+
root = matching[0][1]
|
|
1041
|
+
elif context_file == unknown["context_file"]:
|
|
1042
|
+
root = unknown["skill_root"]
|
|
1043
|
+
else:
|
|
1044
|
+
root = next(e["skill_root"] for e in known_ides.values() if e["context_file"] == context_file)
|
|
1045
|
+
targets = [{"context_file": context_file, "skill_root": root, "ides": [ide for ide, _ in matching]}]
|
|
1046
|
+
others = [ide for ide, cf, _ in mapped if cf != context_file]
|
|
1047
|
+
if others:
|
|
1048
|
+
notes.append(
|
|
1049
|
+
f"Exporting to {context_file} only. config.yaml also lists: {', '.join(others)}. "
|
|
1050
|
+
"Run without `--context-file` to export to all configured IDEs."
|
|
1051
|
+
)
|
|
1052
|
+
else:
|
|
1053
|
+
targets, by_file = [], {}
|
|
1054
|
+
for ide, cf, root in mapped:
|
|
1055
|
+
if cf in by_file:
|
|
1056
|
+
by_file[cf]["ides"].append(ide)
|
|
1057
|
+
else:
|
|
1058
|
+
by_file[cf] = {"context_file": cf, "skill_root": root, "ides": [ide]}
|
|
1059
|
+
targets.append(by_file[cf])
|
|
1060
|
+
for target in targets:
|
|
1061
|
+
if len(target["ides"]) > 1:
|
|
1062
|
+
notes.append(
|
|
1063
|
+
f"Multiple IDEs target {target['context_file']}: using {target['ides'][0]}'s skill root "
|
|
1064
|
+
f"(`{target['skill_root']}`). Each IDE's skills are installed to its own directory."
|
|
1065
|
+
)
|
|
1066
|
+
if not targets:
|
|
1067
|
+
targets = [{"context_file": unknown["context_file"], "skill_root": unknown["skill_root"], "ides": []}]
|
|
1068
|
+
notes.append(
|
|
1069
|
+
f"No IDEs configured in config.yaml: defaulting to {unknown['context_file']} with `{unknown['skill_root']}`."
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
targeted = {t["context_file"] for t in targets}
|
|
1073
|
+
return {
|
|
1074
|
+
"status": "ok",
|
|
1075
|
+
"targets": targets,
|
|
1076
|
+
"ide_map": {ide: cf for ide, cf, _ in mapped},
|
|
1077
|
+
"unknown_ides": unknown_ides,
|
|
1078
|
+
"other_context_files": [cf for cf in known_files if cf not in targeted],
|
|
1079
|
+
"warnings": warnings,
|
|
1080
|
+
"notes": notes,
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
# ---------------------------------------------------------------------------
|
|
1085
|
+
# CLI
|
|
1086
|
+
# ---------------------------------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
ACTION_FIRST = ("orphan-detect", "root-probe", "assemble", "resolve-targets")
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _include_list(value):
|
|
1092
|
+
"""--include: NAME@VERSION, or several separated by commas."""
|
|
1093
|
+
items = []
|
|
1094
|
+
for part in filter(None, (p.strip() for p in value.split(","))):
|
|
1095
|
+
name, sep, version = part.partition("@")
|
|
1096
|
+
if not sep or not is_plain_name(name) or not is_plain_name(version):
|
|
1097
|
+
raise argparse.ArgumentTypeError(f"expected NAME@VERSION with plain folder names, got {part!r}")
|
|
1098
|
+
items.append((name, version))
|
|
1099
|
+
return items
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _name_list(value):
|
|
1103
|
+
"""--dropped: a skill name, or several separated by commas."""
|
|
1104
|
+
names = [part for part in (p.strip() for p in value.split(",")) if part]
|
|
1105
|
+
if not all(is_plain_name(n) for n in names):
|
|
1106
|
+
raise argparse.ArgumentTypeError(f"expected skill names, got {value!r}")
|
|
1107
|
+
return names
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _renamed_pair(value):
|
|
1111
|
+
"""--renamed: OLD:NEW."""
|
|
1112
|
+
old, sep, new = value.partition(":")
|
|
1113
|
+
if not sep or not is_plain_name(old) or not is_plain_name(new) or old == new:
|
|
1114
|
+
raise argparse.ArgumentTypeError(f"expected OLD:NEW with two different skill names, got {value!r}")
|
|
1115
|
+
return old, new
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _snippet_pair(value):
|
|
1119
|
+
"""--snippet: NAME=FILE."""
|
|
1120
|
+
name, sep, path = value.partition("=")
|
|
1121
|
+
if not sep or not is_plain_name(name) or not path:
|
|
1122
|
+
raise argparse.ArgumentTypeError(f"expected NAME=FILE, got {value!r}")
|
|
1123
|
+
return name, path
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _action_parser(action):
|
|
1127
|
+
"""The argparse parser of one action-first action."""
|
|
1128
|
+
prog = f"skf-rebuild-managed-sections.py {action}"
|
|
370
1129
|
if action == "orphan-detect":
|
|
371
1130
|
parser = argparse.ArgumentParser(
|
|
372
|
-
prog="
|
|
373
|
-
description="Detect managed-section rows absent from the exported skill set.",
|
|
1131
|
+
prog=prog, description="Detect managed-section rows absent from the exported skill set."
|
|
374
1132
|
)
|
|
375
1133
|
parser.add_argument("context_files", nargs="+", help="Context file paths to scan")
|
|
376
1134
|
parser.add_argument(
|
|
@@ -378,34 +1136,152 @@ def _run_query_action(argv):
|
|
|
378
1136
|
default="",
|
|
379
1137
|
help="Comma-separated skill names being exported (excluded from orphans)",
|
|
380
1138
|
)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1139
|
+
elif action == "root-probe":
|
|
1140
|
+
parser = argparse.ArgumentParser(
|
|
1141
|
+
prog=prog, description="Observe snippet root prefixes and flag mismatch against a reference."
|
|
1142
|
+
)
|
|
1143
|
+
parser.add_argument("snippet_files", nargs="+", help="Snippet file paths to probe")
|
|
1144
|
+
parser.add_argument(
|
|
1145
|
+
"--reference-root",
|
|
1146
|
+
required=True,
|
|
1147
|
+
help="Reference skill root prefix to compare observed prefixes against",
|
|
1148
|
+
)
|
|
1149
|
+
parser.add_argument(
|
|
1150
|
+
"--project-root",
|
|
1151
|
+
help="The project the roots are relative to: report in disk_root which root holds "
|
|
1152
|
+
"the mismatched skills",
|
|
1153
|
+
)
|
|
1154
|
+
elif action == "assemble":
|
|
1155
|
+
parser = argparse.ArgumentParser(
|
|
1156
|
+
prog=prog,
|
|
1157
|
+
description="Build a context file's managed-section body from the export manifest and the "
|
|
1158
|
+
"skill snippets, and write it to <context-file>.skf-content (or --out FILE) for replace or insert.",
|
|
1159
|
+
)
|
|
1160
|
+
parser.add_argument("context_file", help="The context file the body is for")
|
|
1161
|
+
parser.add_argument("--skills-folder", required=True, help="skills_output_folder, which holds .export-manifest.json")
|
|
1162
|
+
parser.add_argument("--skill-root", required=True, help="The context file's skill root, such as .claude/skills/")
|
|
1163
|
+
parser.add_argument(
|
|
1164
|
+
"--skill-root-override",
|
|
1165
|
+
default="",
|
|
1166
|
+
help="snippet_skill_root_override from config.yaml: every row's root uses it instead of --skill-root",
|
|
1167
|
+
)
|
|
1168
|
+
parser.add_argument(
|
|
1169
|
+
"--include",
|
|
1170
|
+
action="append",
|
|
1171
|
+
type=_include_list,
|
|
1172
|
+
default=[],
|
|
1173
|
+
metavar="NAME@VERSION",
|
|
1174
|
+
help="A skill to write at this version whatever the manifest says (the skills being exported)",
|
|
1175
|
+
)
|
|
1176
|
+
parser.add_argument(
|
|
1177
|
+
"--snippet",
|
|
1178
|
+
action="append",
|
|
1179
|
+
type=_snippet_pair,
|
|
1180
|
+
default=[],
|
|
1181
|
+
metavar="NAME=FILE",
|
|
1182
|
+
help="Read NAME's snippet from FILE instead of its package",
|
|
1183
|
+
)
|
|
1184
|
+
parser.add_argument(
|
|
1185
|
+
"--snippet-dir",
|
|
1186
|
+
metavar="DIR",
|
|
1187
|
+
help="A folder of staged snippet drafts: a skill with DIR/<name>/context-snippet.md "
|
|
1188
|
+
"reads it instead of its package (--snippet wins over it)",
|
|
1189
|
+
)
|
|
1190
|
+
parser.add_argument("--renamed", type=_renamed_pair, metavar="OLD:NEW", help="A rename: OLD's rows are not orphans")
|
|
1191
|
+
parser.add_argument(
|
|
1192
|
+
"--dropped",
|
|
1193
|
+
action="append",
|
|
1194
|
+
type=_name_list,
|
|
1195
|
+
default=[],
|
|
1196
|
+
metavar="NAME",
|
|
1197
|
+
help="A dropped skill: its rows are not orphans",
|
|
1198
|
+
)
|
|
1199
|
+
parser.add_argument(
|
|
1200
|
+
"--orphan-sources",
|
|
1201
|
+
nargs="+",
|
|
1202
|
+
default=[],
|
|
1203
|
+
metavar="FILE",
|
|
1204
|
+
help="Every target context file, scanned for orphan rows (the context file itself always is)",
|
|
1205
|
+
)
|
|
1206
|
+
parser.add_argument(
|
|
1207
|
+
"--orphans",
|
|
1208
|
+
choices=("keep", "drop"),
|
|
1209
|
+
default="keep",
|
|
1210
|
+
help="Write the orphan rows verbatim (keep, the default) or leave them out (drop)",
|
|
1211
|
+
)
|
|
1212
|
+
parser.add_argument(
|
|
1213
|
+
"--out",
|
|
1214
|
+
metavar="FILE",
|
|
1215
|
+
help="Write the body to FILE instead of <context-file>.skf-content (a dry run, or a preview "
|
|
1216
|
+
"before the user confirms, passes a path outside the project)",
|
|
1217
|
+
)
|
|
1218
|
+
else: # resolve-targets
|
|
1219
|
+
parser = argparse.ArgumentParser(
|
|
1220
|
+
prog=prog, description="Map config.yaml's IDE list to the context files to write and their skill roots."
|
|
1221
|
+
)
|
|
1222
|
+
parser.add_argument("--ides", default="", help="Comma-separated IDE identifiers from config.yaml `ides`")
|
|
1223
|
+
parser.add_argument(
|
|
1224
|
+
"--context-file",
|
|
1225
|
+
default="",
|
|
1226
|
+
help="Keep only this context file (CLAUDE.md, .cursorrules or AGENTS.md)",
|
|
1227
|
+
)
|
|
1228
|
+
return parser
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _run_action_first(argv):
|
|
1232
|
+
"""Parse and run an action-first action. Returns (result_dict, exit_code).
|
|
1233
|
+
|
|
1234
|
+
These take the action name first (`orphan-detect <file>...`) rather than
|
|
1235
|
+
the legacy `<file> <action>` shape, so they are parsed with argparse and
|
|
1236
|
+
routed here before the legacy path.
|
|
1237
|
+
"""
|
|
1238
|
+
action = argv[0]
|
|
1239
|
+
parser = _action_parser(action)
|
|
394
1240
|
args = parser.parse_args(argv[1:])
|
|
395
|
-
|
|
1241
|
+
if action == "orphan-detect":
|
|
1242
|
+
result = cmd_orphan_detect(args.context_files, args.exported_skills)
|
|
1243
|
+
elif action == "root-probe":
|
|
1244
|
+
result = cmd_root_probe(args.snippet_files, args.reference_root, args.project_root)
|
|
1245
|
+
elif action == "assemble":
|
|
1246
|
+
includes = [item for group in args.include for item in group]
|
|
1247
|
+
names = [name for name, _ in includes]
|
|
1248
|
+
if len(set(names)) != len(names):
|
|
1249
|
+
parser.error("--include names a skill twice")
|
|
1250
|
+
snippet_names = [name for name, _ in args.snippet]
|
|
1251
|
+
if len(set(snippet_names)) != len(snippet_names):
|
|
1252
|
+
parser.error("--snippet names a skill twice")
|
|
1253
|
+
result = cmd_assemble(
|
|
1254
|
+
args.context_file,
|
|
1255
|
+
args.skills_folder,
|
|
1256
|
+
args.skill_root,
|
|
1257
|
+
override=args.skill_root_override,
|
|
1258
|
+
includes=includes,
|
|
1259
|
+
snippets=dict(args.snippet),
|
|
1260
|
+
snippet_dir=args.snippet_dir,
|
|
1261
|
+
renamed=args.renamed,
|
|
1262
|
+
dropped=[name for group in args.dropped for name in group],
|
|
1263
|
+
orphan_sources=args.orphan_sources,
|
|
1264
|
+
orphans=args.orphans,
|
|
1265
|
+
out=args.out,
|
|
1266
|
+
)
|
|
1267
|
+
else:
|
|
1268
|
+
result = cmd_resolve_targets(args.ides, args.context_file)
|
|
1269
|
+
return result, 0 if result["status"] == "ok" else 1
|
|
396
1270
|
|
|
397
1271
|
|
|
398
1272
|
def main():
|
|
399
|
-
#
|
|
400
|
-
# (`orphan-detect <file>...`) rather than the legacy
|
|
401
|
-
|
|
402
|
-
|
|
1273
|
+
# Action-first actions take a positional file list or flags only, so the
|
|
1274
|
+
# action comes first (`orphan-detect <file>...`) rather than the legacy
|
|
1275
|
+
# `<file> <action>` shape.
|
|
1276
|
+
if len(sys.argv) >= 2 and sys.argv[1] in ACTION_FIRST:
|
|
1277
|
+
result, code = _run_action_first(sys.argv[1:])
|
|
403
1278
|
print(json.dumps(result, indent=2))
|
|
404
1279
|
sys.exit(code)
|
|
405
1280
|
|
|
406
1281
|
if len(sys.argv) < 3:
|
|
407
1282
|
print("Usage: python3 skf-rebuild-managed-sections.py <context-file> <action> [--content <text>]", file=sys.stderr)
|
|
408
1283
|
print("Actions: read, replace, clear, insert, check", file=sys.stderr)
|
|
1284
|
+
print(" or: python3 skf-rebuild-managed-sections.py {" + ",".join(ACTION_FIRST) + "} ...", file=sys.stderr)
|
|
409
1285
|
sys.exit(1)
|
|
410
1286
|
|
|
411
1287
|
file_path = sys.argv[1]
|
|
@@ -417,7 +1293,15 @@ def main():
|
|
|
417
1293
|
if idx + 1 < len(sys.argv):
|
|
418
1294
|
content_arg = sys.argv[idx + 1]
|
|
419
1295
|
elif action in ("replace", "insert") and not sys.stdin.isatty():
|
|
420
|
-
|
|
1296
|
+
# Read the bytes and decode them as UTF-8 ourselves: sys.stdin decodes
|
|
1297
|
+
# a pipe or redirect with the locale's code page (cp1252 on Windows),
|
|
1298
|
+
# which turns the em dash every snippet's IMPORTANT line carries into
|
|
1299
|
+
# mojibake that the byte-identity check then accepts.
|
|
1300
|
+
try:
|
|
1301
|
+
content_arg = sys.stdin.buffer.read().decode("utf-8")
|
|
1302
|
+
except UnicodeDecodeError as e:
|
|
1303
|
+
print(json.dumps({"status": "error", "error": f"content on stdin is not UTF-8: {e}"}, indent=2))
|
|
1304
|
+
sys.exit(1)
|
|
421
1305
|
|
|
422
1306
|
if action == "check":
|
|
423
1307
|
result = cmd_check(file_path)
|