bmad-module-skill-forge 0.9.0 → 0.10.1-alpha.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 +39 -0
- package/.gitattributes +16 -0
- package/README.md +105 -38
- package/docs/404.md +13 -10
- package/docs/RELEASING.md +185 -0
- package/docs/STABILITY.md +123 -0
- package/docs/_data/pinned.yaml +98 -0
- package/docs/agents.md +16 -11
- package/docs/architecture.md +221 -0
- package/docs/bmad-synergy.md +11 -13
- package/docs/concepts.md +18 -108
- package/docs/examples.md +121 -80
- package/docs/getting-started.md +85 -147
- package/docs/how-it-works.md +25 -536
- package/docs/index.md +44 -27
- package/docs/skill-model.md +328 -0
- package/docs/troubleshooting.md +39 -0
- package/docs/verifying-a-skill.md +232 -0
- package/docs/why-skf.md +93 -0
- package/docs/workflows.md +119 -13
- package/package.json +25 -4
- package/src/README.md +23 -25
- package/src/forger/forge-tier.yaml +4 -2
- package/src/forger/preferences.yaml +7 -1
- package/src/knowledge/agentskills-spec.md +1 -1
- package/src/knowledge/architecture-verification.md +1 -1
- package/src/knowledge/ccc-bridge.md +16 -15
- package/src/knowledge/overview.md +11 -11
- package/src/knowledge/progressive-capability.md +3 -3
- package/src/knowledge/provenance-tracking.md +9 -3
- package/src/knowledge/qmd-registry.md +8 -8
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +16 -1
- package/src/knowledge/split-body-strategy.md +1 -1
- package/src/knowledge/version-paths.md +17 -12
- package/src/module-help.csv +18 -17
- package/src/module.yaml +4 -0
- package/src/shared/health-check.md +372 -0
- package/src/shared/references/feasibility-report-schema.md +86 -0
- package/src/shared/references/headless-gate-convention.md +62 -0
- package/src/shared/references/output-contract-schema.md +35 -0
- package/src/shared/references/pipeline-contracts.md +102 -0
- package/src/shared/scripts/skf-atomic-write.py +370 -0
- package/src/shared/scripts/skf-manifest-ops.py +236 -0
- package/src/shared/scripts/skf-preflight.py +164 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +201 -0
- package/src/shared/scripts/skf-severity-classify.py +163 -0
- package/src/shared/scripts/skf-skill-inventory.py +210 -0
- package/src/shared/scripts/skf-structural-diff.py +257 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +318 -0
- package/src/shared/scripts/skf-validate-output.py +247 -0
- package/src/skf-analyze-source/SKILL.md +57 -0
- package/src/skf-analyze-source/assets/skill-brief-schema.md +125 -0
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +124 -0
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-01-init.md +6 -66
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-01b-continue.md +4 -59
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-02-scan-project.md +7 -68
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-03-identify-units.md +6 -66
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-04-map-and-detect.md +7 -68
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-05-recommend.md +6 -66
- package/src/{workflows/analyze-source → skf-analyze-source}/steps-c/step-06-generate-briefs.md +14 -66
- package/src/skf-analyze-source/steps-c/step-07-health-check.md +22 -0
- package/src/skf-audit-skill/SKILL.md +59 -0
- package/src/{workflows/audit-skill/data → skf-audit-skill/assets}/drift-report-template.md +7 -7
- package/src/{workflows/audit-skill/data → skf-audit-skill/references}/severity-rules.md +11 -11
- package/src/{workflows/audit-skill → skf-audit-skill}/steps-c/step-01-init.md +24 -68
- package/src/{workflows/audit-skill → skf-audit-skill}/steps-c/step-02-re-index.md +36 -71
- package/src/{workflows/audit-skill → skf-audit-skill}/steps-c/step-03-structural-diff.md +21 -63
- package/src/{workflows/audit-skill → skf-audit-skill}/steps-c/step-04-semantic-diff.md +6 -65
- package/src/{workflows/audit-skill → skf-audit-skill}/steps-c/step-05-severity-classify.md +5 -63
- package/src/{workflows/audit-skill → skf-audit-skill}/steps-c/step-06-report.md +10 -59
- package/src/skf-audit-skill/steps-c/step-07-health-check.md +22 -0
- package/src/skf-brief-skill/SKILL.md +53 -0
- package/src/{workflows/brief-skill/data → skf-brief-skill/assets}/scope-templates.md +23 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +206 -0
- package/src/{workflows/brief-skill → skf-brief-skill}/steps-c/step-01-gather-intent.md +6 -67
- package/src/{workflows/brief-skill → skf-brief-skill}/steps-c/step-02-analyze-target.md +6 -66
- package/src/{workflows/brief-skill → skf-brief-skill}/steps-c/step-03-scope-definition.md +12 -68
- package/src/{workflows/brief-skill → skf-brief-skill}/steps-c/step-04-confirm-brief.md +5 -63
- package/src/{workflows/brief-skill → skf-brief-skill}/steps-c/step-05-write-brief.md +10 -69
- package/src/skf-brief-skill/steps-c/step-06-health-check.md +22 -0
- package/src/skf-create-skill/SKILL.md +63 -0
- package/src/{workflows/create-skill/data → skf-create-skill/assets}/compile-assembly-rules.md +60 -0
- package/src/{workflows/create-skill/data → skf-create-skill/assets}/skill-sections.md +92 -23
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +102 -0
- package/src/{workflows/create-skill/data → skf-create-skill/references}/extraction-patterns-tracing.md +13 -13
- package/src/{workflows/create-skill/data → skf-create-skill/references}/extraction-patterns.md +8 -3
- package/src/skf-create-skill/references/source-resolution-protocols.md +239 -0
- package/src/{workflows/create-skill/data → skf-create-skill/references}/tier-degradation-rules.md +8 -7
- package/src/{workflows/create-skill → skf-create-skill}/steps-c/step-01-load-brief.md +25 -63
- package/src/{workflows/create-skill → skf-create-skill}/steps-c/step-02-ecosystem-check.md +8 -64
- package/src/skf-create-skill/steps-c/step-03-extract.md +379 -0
- package/src/{workflows/create-skill → skf-create-skill}/steps-c/step-03d-component-extraction.md +18 -35
- package/src/{workflows/create-skill → skf-create-skill}/steps-c/step-04-enrich.md +12 -69
- package/src/{workflows/create-skill → skf-create-skill}/steps-c/step-05-compile.md +71 -75
- package/src/skf-create-skill/steps-c/step-06-validate.md +267 -0
- package/src/{workflows/create-skill → skf-create-skill}/steps-c/step-07-generate-artifacts.md +30 -74
- package/src/skf-create-skill/steps-c/step-08-report.md +145 -0
- package/src/skf-create-skill/steps-c/step-09-health-check.md +23 -0
- package/src/{workflows/create-skill/steps-c → skf-create-skill/steps-c/sub}/step-02b-ccc-discover.md +8 -66
- package/src/skf-create-skill/steps-c/sub/step-03b-fetch-temporal.md +229 -0
- package/src/{workflows/create-skill/steps-c → skf-create-skill/steps-c/sub}/step-03c-fetch-docs.md +18 -42
- package/src/skf-create-stack-skill/SKILL.md +64 -0
- package/src/{workflows/create-stack-skill/data → skf-create-stack-skill/assets}/stack-skill-template.md +2 -1
- package/src/{workflows/create-stack-skill/data → skf-create-stack-skill/references}/compose-mode-rules.md +19 -5
- package/src/{workflows/create-stack-skill/data → skf-create-stack-skill/references}/integration-patterns.md +1 -1
- package/src/{workflows/create-stack-skill/data → skf-create-stack-skill/references}/manifest-patterns.md +10 -10
- package/src/{workflows/create-stack-skill → skf-create-stack-skill}/steps-c/step-01-init.md +14 -55
- package/src/{workflows/create-stack-skill → skf-create-stack-skill}/steps-c/step-02-detect-manifests.md +29 -63
- package/src/{workflows/create-stack-skill → skf-create-stack-skill}/steps-c/step-03-rank-and-confirm.md +9 -59
- package/src/{workflows/create-stack-skill → skf-create-stack-skill}/steps-c/step-04-parallel-extract.md +29 -64
- package/src/skf-create-stack-skill/steps-c/step-05-detect-integrations.md +164 -0
- package/src/{workflows/create-stack-skill → skf-create-stack-skill}/steps-c/step-06-compile-stack.md +7 -60
- package/src/skf-create-stack-skill/steps-c/step-07-generate-output.md +328 -0
- package/src/{workflows/create-stack-skill → skf-create-stack-skill}/steps-c/step-08-validate.md +17 -63
- package/src/skf-create-stack-skill/steps-c/step-09-report.md +128 -0
- package/src/skf-create-stack-skill/steps-c/step-10-health-check.md +22 -0
- package/src/skf-drop-skill/SKILL.md +57 -0
- package/src/{workflows/drop-skill → skf-drop-skill}/steps-c/step-01-select.md +9 -69
- package/src/{workflows/drop-skill → skf-drop-skill}/steps-c/step-02-execute.md +30 -106
- package/src/skf-drop-skill/steps-c/step-03-report.md +83 -0
- package/src/skf-drop-skill/steps-c/step-04-health-check.md +22 -0
- package/src/skf-export-skill/SKILL.md +57 -0
- package/src/skf-export-skill/assets/managed-section-format.md +138 -0
- package/src/{workflows/export-skill/data → skf-export-skill/assets}/snippet-format.md +8 -12
- package/src/skf-export-skill/steps-c/step-01-load-skill.md +247 -0
- package/src/{workflows/export-skill → skf-export-skill}/steps-c/step-02-package.md +5 -58
- package/src/{workflows/export-skill → skf-export-skill}/steps-c/step-03-generate-snippet.md +15 -77
- package/src/{workflows/export-skill → skf-export-skill}/steps-c/step-04-update-context.md +59 -133
- package/src/{workflows/export-skill → skf-export-skill}/steps-c/step-05-token-report.md +4 -58
- package/src/{workflows/export-skill → skf-export-skill}/steps-c/step-06-summary.md +20 -64
- package/src/skf-export-skill/steps-c/step-07-health-check.md +22 -0
- package/src/skf-forger/SKILL.md +110 -0
- package/src/skf-forger/bmad-skill-manifest.yaml +11 -0
- package/src/skf-quick-skill/SKILL.md +56 -0
- package/src/{workflows/quick-skill/data → skf-quick-skill/references}/registry-resolution.md +6 -6
- package/src/{workflows/quick-skill → skf-quick-skill}/steps-c/step-01-resolve-target.md +9 -62
- package/src/{workflows/quick-skill → skf-quick-skill}/steps-c/step-02-ecosystem-check.md +5 -59
- package/src/{workflows/quick-skill → skf-quick-skill}/steps-c/step-03-quick-extract.md +18 -61
- package/src/{workflows/quick-skill → skf-quick-skill}/steps-c/step-04-compile.md +6 -60
- package/src/{workflows/quick-skill → skf-quick-skill}/steps-c/step-05-validate.md +58 -78
- package/src/skf-quick-skill/steps-c/step-06-write.md +73 -0
- package/src/skf-quick-skill/steps-c/step-07-health-check.md +22 -0
- package/src/skf-refine-architecture/SKILL.md +57 -0
- package/src/{workflows/refine-architecture/data → skf-refine-architecture/references}/refinement-rules.md +16 -16
- package/src/{workflows/refine-architecture → skf-refine-architecture}/steps-c/step-01-init.md +6 -63
- package/src/{workflows/refine-architecture → skf-refine-architecture}/steps-c/step-02-gap-analysis.md +29 -71
- package/src/{workflows/refine-architecture → skf-refine-architecture}/steps-c/step-03-issue-detection.md +5 -65
- package/src/{workflows/refine-architecture → skf-refine-architecture}/steps-c/step-04-improvements.md +5 -64
- package/src/{workflows/refine-architecture → skf-refine-architecture}/steps-c/step-05-compile.md +5 -56
- package/src/{workflows/refine-architecture → skf-refine-architecture}/steps-c/step-06-report.md +14 -64
- package/src/skf-refine-architecture/steps-c/step-07-health-check.md +22 -0
- package/src/skf-rename-skill/SKILL.md +58 -0
- package/src/{workflows/rename-skill → skf-rename-skill}/steps-c/step-01-select.md +12 -73
- package/src/{workflows/rename-skill → skf-rename-skill}/steps-c/step-02-execute.md +32 -113
- package/src/skf-rename-skill/steps-c/step-03-report.md +83 -0
- package/src/skf-rename-skill/steps-c/step-04-health-check.md +22 -0
- package/src/skf-setup/SKILL.md +55 -0
- package/src/{workflows/setup-forge → skf-setup}/steps-c/step-01-detect-and-tier.md +6 -62
- package/src/{workflows/setup-forge → skf-setup}/steps-c/step-01b-ccc-index.md +9 -65
- package/src/{workflows/setup-forge → skf-setup}/steps-c/step-02-write-config.md +15 -60
- package/src/{workflows/setup-forge → skf-setup}/steps-c/step-03-auto-index.md +7 -66
- package/src/skf-setup/steps-c/step-04-report.md +92 -0
- package/src/skf-setup/steps-c/step-05-health-check.md +22 -0
- package/src/skf-test-skill/SKILL.md +59 -0
- package/src/skf-test-skill/references/migration-section-rules.md +114 -0
- package/src/skf-test-skill/references/scoring-rules.md +207 -0
- package/src/skf-test-skill/references/source-access-protocol.md +109 -0
- package/src/skf-test-skill/scripts/compute-score.py +310 -0
- package/src/skf-test-skill/steps-c/step-01-init.md +238 -0
- package/src/{workflows/test-skill → skf-test-skill}/steps-c/step-02-detect-mode.md +4 -62
- package/src/skf-test-skill/steps-c/step-03-coverage-check.md +347 -0
- package/src/skf-test-skill/steps-c/step-04-coherence-check.md +239 -0
- package/src/{workflows/test-skill → skf-test-skill}/steps-c/step-04b-external-validators.md +42 -77
- package/src/skf-test-skill/steps-c/step-05-score.md +266 -0
- package/src/skf-test-skill/steps-c/step-06-report.md +290 -0
- package/src/skf-test-skill/steps-c/step-07-health-check.md +25 -0
- package/src/skf-test-skill/templates/test-report-template.md +58 -0
- package/src/skf-update-skill/SKILL.md +58 -0
- package/src/{workflows/update-skill/data → skf-update-skill/references}/manual-section-rules.md +6 -6
- package/src/{workflows/update-skill/data → skf-update-skill/references}/merge-conflict-rules.md +5 -5
- package/src/skf-update-skill/references/remote-source-resolution.md +94 -0
- package/src/{workflows/update-skill → skf-update-skill}/steps-c/step-01-init.md +22 -69
- package/src/skf-update-skill/steps-c/step-02-detect-changes.md +255 -0
- package/src/skf-update-skill/steps-c/step-03-re-extract.md +336 -0
- package/src/{workflows/update-skill → skf-update-skill}/steps-c/step-04-merge.md +60 -72
- package/src/{workflows/update-skill → skf-update-skill}/steps-c/step-05-validate.md +5 -62
- package/src/skf-update-skill/steps-c/step-06-write.md +278 -0
- package/src/{workflows/update-skill → skf-update-skill}/steps-c/step-07-report.md +11 -66
- package/src/skf-update-skill/steps-c/step-08-health-check.md +22 -0
- package/src/skf-verify-stack/SKILL.md +60 -0
- package/src/skf-verify-stack/assets/feasibility-report-template.md +76 -0
- package/src/{workflows/verify-stack/data → skf-verify-stack/references}/coverage-patterns.md +12 -4
- package/src/skf-verify-stack/references/integration-verification-rules.md +78 -0
- package/src/skf-verify-stack/steps-c/step-01-init.md +178 -0
- package/src/{workflows/verify-stack → skf-verify-stack}/steps-c/step-02-coverage.md +27 -72
- package/src/skf-verify-stack/steps-c/step-03-integrations.md +179 -0
- package/src/{workflows/verify-stack → skf-verify-stack}/steps-c/step-04-requirements.md +14 -67
- package/src/skf-verify-stack/steps-c/step-05-synthesize.md +147 -0
- package/src/skf-verify-stack/steps-c/step-06-report.md +156 -0
- package/src/skf-verify-stack/steps-c/step-07-health-check.md +25 -0
- package/tools/cli/commands/install.js +1 -1
- package/tools/cli/commands/status.js +21 -23
- package/tools/cli/commands/uninstall.js +11 -19
- package/tools/cli/commands/update.js +2 -2
- package/tools/cli/lib/ide-skills.js +216 -0
- package/tools/cli/lib/installer.js +29 -52
- package/tools/cli/lib/manifest.js +11 -5
- package/tools/cli/lib/platform-codes.yaml +223 -0
- package/tools/cli/lib/ui.js +76 -47
- package/tools/validate-docs-drift.js +193 -0
- package/src/agents/forger.agent.yaml +0 -114
- package/src/workflows/README.md +0 -174
- package/src/workflows/analyze-source/data/skill-brief-schema.md +0 -125
- package/src/workflows/analyze-source/data/unit-detection-heuristics.md +0 -124
- package/src/workflows/analyze-source/workflow.md +0 -61
- package/src/workflows/audit-skill/workflow.md +0 -64
- package/src/workflows/brief-skill/data/skill-brief-schema.md +0 -159
- package/src/workflows/brief-skill/workflow.md +0 -58
- package/src/workflows/create-skill/data/source-resolution-protocols.md +0 -200
- package/src/workflows/create-skill/steps-c/step-03-extract.md +0 -301
- package/src/workflows/create-skill/steps-c/step-03b-fetch-temporal.md +0 -253
- package/src/workflows/create-skill/steps-c/step-06-validate.md +0 -254
- package/src/workflows/create-skill/steps-c/step-08-report.md +0 -176
- package/src/workflows/create-skill/workflow.md +0 -53
- package/src/workflows/create-stack-skill/steps-c/step-05-detect-integrations.md +0 -196
- package/src/workflows/create-stack-skill/steps-c/step-07-generate-output.md +0 -270
- package/src/workflows/create-stack-skill/steps-c/step-09-report.md +0 -145
- package/src/workflows/create-stack-skill/workflow.md +0 -59
- package/src/workflows/drop-skill/steps-c/step-03-report.md +0 -135
- package/src/workflows/drop-skill/workflow.md +0 -63
- package/src/workflows/export-skill/data/managed-section-format.md +0 -103
- package/src/workflows/export-skill/steps-c/step-01-load-skill.md +0 -237
- package/src/workflows/export-skill/workflow.md +0 -58
- package/src/workflows/quick-skill/steps-c/step-06-write.md +0 -179
- package/src/workflows/quick-skill/workflow.md +0 -58
- package/src/workflows/refine-architecture/workflow.md +0 -61
- package/src/workflows/rename-skill/steps-c/step-03-report.md +0 -132
- package/src/workflows/rename-skill/workflow.md +0 -64
- package/src/workflows/setup-forge/steps-c/step-04-report.md +0 -156
- package/src/workflows/setup-forge/workflow.md +0 -51
- package/src/workflows/test-skill/data/scoring-rules.md +0 -123
- package/src/workflows/test-skill/data/source-access-protocol.md +0 -51
- package/src/workflows/test-skill/steps-c/step-01-init.md +0 -217
- package/src/workflows/test-skill/steps-c/step-03-coverage-check.md +0 -280
- package/src/workflows/test-skill/steps-c/step-04-coherence-check.md +0 -271
- package/src/workflows/test-skill/steps-c/step-05-score.md +0 -222
- package/src/workflows/test-skill/steps-c/step-06-report.md +0 -207
- package/src/workflows/test-skill/templates/test-report-template.md +0 -28
- package/src/workflows/test-skill/workflow.md +0 -57
- package/src/workflows/update-skill/data/remote-source-resolution.md +0 -57
- package/src/workflows/update-skill/steps-c/step-02-detect-changes.md +0 -221
- package/src/workflows/update-skill/steps-c/step-03-re-extract.md +0 -256
- package/src/workflows/update-skill/steps-c/step-06-write.md +0 -265
- package/src/workflows/update-skill/workflow.md +0 -56
- package/src/workflows/verify-stack/data/feasibility-report-template.md +0 -63
- package/src/workflows/verify-stack/data/integration-verification-rules.md +0 -73
- package/src/workflows/verify-stack/steps-c/step-01-init.md +0 -179
- package/src/workflows/verify-stack/steps-c/step-03-integrations.md +0 -195
- package/src/workflows/verify-stack/steps-c/step-05-synthesize.md +0 -191
- package/src/workflows/verify-stack/steps-c/step-06-report.md +0 -197
- package/src/workflows/verify-stack/workflow.md +0 -61
- package/tools/cli/lib/compiler.js +0 -306
- package/tools/cli/lib/ide-commands.js +0 -244
- /package/src/{workflows/analyze-source → skf-analyze-source}/templates/analysis-report-template.md +0 -0
- /package/src/{workflows/quick-skill/data → skf-quick-skill/assets}/skill-template.md +0 -0
- /package/src/{workflows/setup-forge/data → skf-setup/references}/tier-rules.md +0 -0
- /package/src/{workflows/test-skill/data → skf-test-skill/assets}/output-section-formats.md +0 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Atomic Write — Crash-safe artifact writing for skill workflows.
|
|
6
|
+
|
|
7
|
+
Provides three CLI subcommands skills can invoke via bash to avoid
|
|
8
|
+
partial-write corruption and active-symlink races.
|
|
9
|
+
|
|
10
|
+
Subcommands:
|
|
11
|
+
write Stage content into <target>.skf-tmp, fsync, then rename to <target>.
|
|
12
|
+
Content comes from stdin. Creates parent dirs as needed.
|
|
13
|
+
|
|
14
|
+
stage-dir Create <target>.skf-tmp/ as a staging directory (mkdir -p).
|
|
15
|
+
Caller writes files into it, then calls commit-dir to atomically
|
|
16
|
+
swap it into place as <target>/ (with prior target moved aside
|
|
17
|
+
to <target>.skf-rollback-<pid> and removed on success).
|
|
18
|
+
|
|
19
|
+
commit-dir Atomically swap <target>.skf-tmp/ into <target>/. If <target>/
|
|
20
|
+
exists, move it to <target>.skf-rollback-<pid> first; on failure,
|
|
21
|
+
restore. Supports rollback via --rollback to undo the most recent
|
|
22
|
+
commit by restoring the rollback dir if still present.
|
|
23
|
+
|
|
24
|
+
flip-link Atomically update symlink <link> to point at <target> using
|
|
25
|
+
the `ln -sfn tmp && mv -Tf tmp link` pattern (or equivalent via
|
|
26
|
+
os.replace on the link path). Holds an flock on <link>.lock.
|
|
27
|
+
|
|
28
|
+
Cross-platform: locking branches between fcntl (POSIX) and msvcrt
|
|
29
|
+
(Windows). Symlink semantics on Windows require dev mode or admin —
|
|
30
|
+
flip-link surfaces a clear error rather than silently falling back.
|
|
31
|
+
Native Windows is untested in CI; the supported path is WSL2.
|
|
32
|
+
|
|
33
|
+
Exit codes:
|
|
34
|
+
0 on success
|
|
35
|
+
1 on user error (bad args, missing input)
|
|
36
|
+
2 on operation failure (disk full, permission, race-detected)
|
|
37
|
+
|
|
38
|
+
CLI examples:
|
|
39
|
+
cat metadata.json | python3 skf-atomic-write.py write --target /path/to/metadata.json
|
|
40
|
+
python3 skf-atomic-write.py stage-dir --target /path/to/1.0.0
|
|
41
|
+
python3 skf-atomic-write.py commit-dir --target /path/to/1.0.0
|
|
42
|
+
python3 skf-atomic-write.py flip-link --link /path/to/active --target 1.0.0
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import errno
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import shutil
|
|
52
|
+
import subprocess
|
|
53
|
+
import sys
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
if os.name == "nt":
|
|
57
|
+
import msvcrt
|
|
58
|
+
else:
|
|
59
|
+
import fcntl
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _create_symlink_or_junction(target: str, link_path: Path) -> str:
|
|
63
|
+
"""Create a directory link from link_path to target.
|
|
64
|
+
|
|
65
|
+
On POSIX: standard symlink. On Windows: try symlink first (works under
|
|
66
|
+
Developer Mode or admin), fall back to a directory junction via `mklink /J`
|
|
67
|
+
on PRIVILEGE_NOT_HELD / ACCESS_DENIED. Junctions don't need elevation and
|
|
68
|
+
behave like directory symlinks for the resolve() / is_dir() consumers in
|
|
69
|
+
skf-skill-inventory.py.
|
|
70
|
+
|
|
71
|
+
Returns "symlink" or "junction" so callers can report which kind was made.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
os.symlink(target, link_path)
|
|
75
|
+
return "symlink"
|
|
76
|
+
except OSError as e:
|
|
77
|
+
if os.name != "nt" or getattr(e, "winerror", None) not in (1314, 5):
|
|
78
|
+
raise
|
|
79
|
+
# Junctions are absolute-path-only and target must be a real directory
|
|
80
|
+
# at creation time. Resolve relative `target` against link_path's parent.
|
|
81
|
+
abs_target = (link_path.parent / target).resolve()
|
|
82
|
+
if not abs_target.is_dir():
|
|
83
|
+
raise OSError(
|
|
84
|
+
errno.ENOTDIR,
|
|
85
|
+
f"junction fallback requires existing directory target: {abs_target}",
|
|
86
|
+
) from e
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
["cmd", "/c", "mklink", "/J", str(link_path), str(abs_target)],
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
timeout=30,
|
|
92
|
+
)
|
|
93
|
+
if result.returncode != 0:
|
|
94
|
+
raise OSError(
|
|
95
|
+
e.errno,
|
|
96
|
+
f"junction fallback failed: {result.stderr.strip() or result.stdout.strip()}",
|
|
97
|
+
) from e
|
|
98
|
+
return "junction"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _acquire_lock(fd: int) -> None:
|
|
102
|
+
"""Acquire an exclusive non-blocking lock on fd (auto-released on close/exit)."""
|
|
103
|
+
if os.name == "nt":
|
|
104
|
+
try:
|
|
105
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
106
|
+
except OSError as e:
|
|
107
|
+
if e.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
|
|
108
|
+
raise OSError(errno.EAGAIN, "lock held") from e
|
|
109
|
+
raise
|
|
110
|
+
else:
|
|
111
|
+
try:
|
|
112
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
113
|
+
except OSError as e:
|
|
114
|
+
if e.errno in (errno.EAGAIN, errno.EACCES):
|
|
115
|
+
raise OSError(errno.EAGAIN, "lock held") from e
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _release_lock(fd: int) -> None:
|
|
120
|
+
if os.name == "nt":
|
|
121
|
+
try:
|
|
122
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _die(code: int, message: str) -> None:
|
|
133
|
+
print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
|
|
134
|
+
sys.exit(code)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ok(payload: dict) -> None:
|
|
138
|
+
payload.setdefault("status", "ok")
|
|
139
|
+
print(json.dumps(payload))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_write(target: Path) -> None:
|
|
143
|
+
"""Write stdin to target atomically via temp + rename."""
|
|
144
|
+
data = sys.stdin.buffer.read()
|
|
145
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
tmp = target.with_name(target.name + ".skf-tmp")
|
|
147
|
+
try:
|
|
148
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
149
|
+
try:
|
|
150
|
+
os.write(fd, data)
|
|
151
|
+
os.fsync(fd)
|
|
152
|
+
finally:
|
|
153
|
+
os.close(fd)
|
|
154
|
+
os.replace(tmp, target)
|
|
155
|
+
except OSError as e:
|
|
156
|
+
if tmp.exists():
|
|
157
|
+
try:
|
|
158
|
+
tmp.unlink()
|
|
159
|
+
except OSError:
|
|
160
|
+
pass
|
|
161
|
+
_die(2, f"atomic write failed: {e}")
|
|
162
|
+
_ok({"wrote": str(target), "bytes": len(data)})
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_stage_dir(target: Path) -> None:
|
|
166
|
+
"""Create <target>.skf-tmp/ staging directory (clean if present)."""
|
|
167
|
+
staging = target.with_name(target.name + ".skf-tmp")
|
|
168
|
+
# A prior interrupted run can leave staging as a regular file or a dangling
|
|
169
|
+
# symlink; shutil.rmtree only handles real directories, so dispatch by type.
|
|
170
|
+
if staging.is_symlink() or (staging.exists() and not staging.is_dir()):
|
|
171
|
+
try:
|
|
172
|
+
staging.unlink()
|
|
173
|
+
except OSError as e:
|
|
174
|
+
_die(2, f"failed to clear stale staging entry {staging}: {e}")
|
|
175
|
+
elif staging.is_dir():
|
|
176
|
+
shutil.rmtree(staging)
|
|
177
|
+
staging.mkdir(parents=True)
|
|
178
|
+
_ok({"staging": str(staging)})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_commit_dir(target: Path, rollback: bool = False) -> None:
|
|
182
|
+
"""Swap <target>.skf-tmp/ into <target>/.
|
|
183
|
+
|
|
184
|
+
The swap is two os.replace calls (move prior aside, then move staging
|
|
185
|
+
into place); a crash between them leaves the prior snapshot at
|
|
186
|
+
<target>.skf-rollback-<pid>/ recoverable via --rollback. Concurrent
|
|
187
|
+
commits against the same target are NOT supported — callers must
|
|
188
|
+
serialize via the per-target flock provided by flip-link or external
|
|
189
|
+
coordination.
|
|
190
|
+
"""
|
|
191
|
+
staging = target.with_name(target.name + ".skf-tmp")
|
|
192
|
+
rollback_dir = target.with_name(target.name + f".skf-rollback-{os.getpid()}")
|
|
193
|
+
|
|
194
|
+
if rollback:
|
|
195
|
+
# Pick the newest rollback dir by mtime, not by lexical PID sort.
|
|
196
|
+
# Concurrent commits leave one rollback per PID; sorting by PID-as-string
|
|
197
|
+
# could restore an older snapshot (PID "9999" sorts after "10001"). mtime
|
|
198
|
+
# of the rollback dir = moment os.replace moved the prior target aside,
|
|
199
|
+
# which is the correct "most recent" anchor.
|
|
200
|
+
candidates = list(target.parent.glob(target.name + ".skf-rollback-*"))
|
|
201
|
+
if not candidates:
|
|
202
|
+
_die(1, f"no rollback dir for {target}")
|
|
203
|
+
chosen = max(candidates, key=lambda p: p.stat().st_mtime)
|
|
204
|
+
if target.exists():
|
|
205
|
+
shutil.rmtree(target)
|
|
206
|
+
os.replace(chosen, target)
|
|
207
|
+
_ok({"restored": str(target), "from": str(chosen)})
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if not staging.is_dir():
|
|
211
|
+
_die(1, f"staging dir missing: {staging}")
|
|
212
|
+
|
|
213
|
+
# First-install case: target's parent may not exist yet (e.g. fresh
|
|
214
|
+
# {skill_group}/{version}/ install). os.replace requires the destination
|
|
215
|
+
# parent to exist, so create it before either replace fires.
|
|
216
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
prior_moved = False
|
|
219
|
+
if target.exists():
|
|
220
|
+
if target.is_symlink() or target.is_file():
|
|
221
|
+
_die(2, f"target is not a directory: {target}")
|
|
222
|
+
try:
|
|
223
|
+
os.replace(target, rollback_dir)
|
|
224
|
+
prior_moved = True
|
|
225
|
+
except OSError as e:
|
|
226
|
+
_die(2, f"failed to move prior target aside: {e}")
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
os.replace(staging, target)
|
|
230
|
+
except OSError as e:
|
|
231
|
+
if prior_moved:
|
|
232
|
+
try:
|
|
233
|
+
os.replace(rollback_dir, target)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
_die(2, f"commit swap failed: {e}")
|
|
237
|
+
|
|
238
|
+
if prior_moved:
|
|
239
|
+
try:
|
|
240
|
+
shutil.rmtree(rollback_dir)
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
_ok({"committed": str(target)})
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _is_link_or_junction(p: Path) -> bool:
|
|
248
|
+
"""True for POSIX symlinks AND Windows junctions/symlinks.
|
|
249
|
+
|
|
250
|
+
`Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
|
|
251
|
+
for both symlinks and junctions (since CPython 3.8 on Windows). A regular
|
|
252
|
+
directory raises OSError on readlink, which is the signal we want to
|
|
253
|
+
refuse replacement.
|
|
254
|
+
"""
|
|
255
|
+
if p.is_symlink():
|
|
256
|
+
return True
|
|
257
|
+
if not p.exists() and not p.is_symlink():
|
|
258
|
+
return False
|
|
259
|
+
try:
|
|
260
|
+
os.readlink(p)
|
|
261
|
+
return True
|
|
262
|
+
except OSError:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_flip_link(link: Path, target: str) -> None:
|
|
267
|
+
"""Atomically point <link> at <target> using rename-over-symlink pattern.
|
|
268
|
+
|
|
269
|
+
target is the value of the symlink (may be relative, as is convention
|
|
270
|
+
for `active -> 1.0.0`). Held lock on <link>.lock prevents concurrent flips.
|
|
271
|
+
|
|
272
|
+
On Windows, os.symlink requires Developer Mode or admin. When that fails
|
|
273
|
+
with PRIVILEGE_NOT_HELD/ACCESS_DENIED the helper falls back to a directory
|
|
274
|
+
junction (no elevation needed); junctions resolve identically for
|
|
275
|
+
skf-skill-inventory's resolve_active_version().
|
|
276
|
+
"""
|
|
277
|
+
lock_path = link.with_name(link.name + ".skf-lock")
|
|
278
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
# Refuse only if <link> is a real directory/file (not a symlink or junction).
|
|
281
|
+
# Replacing a real dir would lose user data; replacing a link is the point.
|
|
282
|
+
if link.exists() and not _is_link_or_junction(link):
|
|
283
|
+
_die(2, f"refusing to replace non-link: {link}")
|
|
284
|
+
|
|
285
|
+
lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
286
|
+
lock_held = False
|
|
287
|
+
try:
|
|
288
|
+
try:
|
|
289
|
+
_acquire_lock(lock_fd)
|
|
290
|
+
lock_held = True
|
|
291
|
+
except OSError as e:
|
|
292
|
+
if e.errno == errno.EAGAIN:
|
|
293
|
+
_die(2, f"another process holds flip lock on {link}")
|
|
294
|
+
raise
|
|
295
|
+
|
|
296
|
+
tmp_link = link.with_name(link.name + ".skf-tmp-link")
|
|
297
|
+
if tmp_link.is_symlink() or tmp_link.exists():
|
|
298
|
+
# On Windows a junction returns is_dir()=True, exists()=True but
|
|
299
|
+
# is_symlink()=False — must rmdir/unlink based on type.
|
|
300
|
+
if tmp_link.is_dir() and not tmp_link.is_symlink():
|
|
301
|
+
tmp_link.rmdir()
|
|
302
|
+
else:
|
|
303
|
+
tmp_link.unlink()
|
|
304
|
+
try:
|
|
305
|
+
link_kind = _create_symlink_or_junction(target, tmp_link)
|
|
306
|
+
except OSError as e:
|
|
307
|
+
if os.name == "nt" and getattr(e, "winerror", None) in (1314, 5):
|
|
308
|
+
_die(
|
|
309
|
+
2,
|
|
310
|
+
"symlink/junction creation failed on Windows. Junction fallback "
|
|
311
|
+
"requires the target directory to exist. Either enable Developer "
|
|
312
|
+
"Mode (Settings → Privacy & Security → For Developers) or use WSL2.",
|
|
313
|
+
)
|
|
314
|
+
raise
|
|
315
|
+
# os.replace can swap a symlink-over-symlink atomically, but Windows
|
|
316
|
+
# rejects renaming a directory (junction) over an existing directory
|
|
317
|
+
# (junction or real). Drop the existing link first when junction-based.
|
|
318
|
+
if link_kind == "junction" and (link.is_dir() or link.is_symlink() or link.exists()):
|
|
319
|
+
if link.is_symlink() or not link.is_dir():
|
|
320
|
+
link.unlink()
|
|
321
|
+
else:
|
|
322
|
+
link.rmdir()
|
|
323
|
+
os.replace(tmp_link, link)
|
|
324
|
+
finally:
|
|
325
|
+
if lock_held:
|
|
326
|
+
_release_lock(lock_fd)
|
|
327
|
+
os.close(lock_fd)
|
|
328
|
+
# Lock file is bookkeeping only — once the fd is closed the lock is
|
|
329
|
+
# gone, so leaving the file around just litters the skill_group dir.
|
|
330
|
+
# Best-effort unlink; a concurrent flipper may have already removed it.
|
|
331
|
+
try:
|
|
332
|
+
lock_path.unlink()
|
|
333
|
+
except OSError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
_ok({"link": str(link), "points_to": target, "kind": link_kind})
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main() -> None:
|
|
340
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
341
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
342
|
+
|
|
343
|
+
p_write = sub.add_parser("write", help="Atomic file write from stdin")
|
|
344
|
+
p_write.add_argument("--target", type=Path, required=True)
|
|
345
|
+
|
|
346
|
+
p_stage = sub.add_parser("stage-dir", help="Create staging directory")
|
|
347
|
+
p_stage.add_argument("--target", type=Path, required=True)
|
|
348
|
+
|
|
349
|
+
p_commit = sub.add_parser("commit-dir", help="Commit staging directory to target")
|
|
350
|
+
p_commit.add_argument("--target", type=Path, required=True)
|
|
351
|
+
p_commit.add_argument("--rollback", action="store_true", help="Restore from rollback dir instead of committing")
|
|
352
|
+
|
|
353
|
+
p_flip = sub.add_parser("flip-link", help="Atomic symlink flip")
|
|
354
|
+
p_flip.add_argument("--link", type=Path, required=True)
|
|
355
|
+
p_flip.add_argument("--target", type=str, required=True)
|
|
356
|
+
|
|
357
|
+
args = parser.parse_args()
|
|
358
|
+
|
|
359
|
+
if args.cmd == "write":
|
|
360
|
+
cmd_write(args.target)
|
|
361
|
+
elif args.cmd == "stage-dir":
|
|
362
|
+
cmd_stage_dir(args.target)
|
|
363
|
+
elif args.cmd == "commit-dir":
|
|
364
|
+
cmd_commit_dir(args.target, rollback=args.rollback)
|
|
365
|
+
elif args.cmd == "flip-link":
|
|
366
|
+
cmd_flip_link(args.link, args.target)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
main()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Manifest Ops — CRUD operations on the export manifest.
|
|
6
|
+
|
|
7
|
+
Reads, adds, updates, removes entries in .export-manifest.json.
|
|
8
|
+
Used by export-skill, drop-skill, and rename-skill.
|
|
9
|
+
|
|
10
|
+
CLI: python3 skf-manifest-ops.py <skills-folder> <command> [args]
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
read — Read entire manifest
|
|
14
|
+
get <skill-name> — Get a single skill entry
|
|
15
|
+
set <skill-name> <version> — Add/update skill with active version
|
|
16
|
+
remove <skill-name> — Remove skill from manifest
|
|
17
|
+
deprecate <skill-name> [ver] — Mark skill or version as deprecated
|
|
18
|
+
rename <old-name> <new-name> — Rename a skill entry
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_manifest(manifest_path):
|
|
32
|
+
"""Read manifest file, returning (data, None) or (None, error).
|
|
33
|
+
|
|
34
|
+
Handles both v1 (flat list) and v2 (dict) manifest formats.
|
|
35
|
+
V1 manifests are migrated in-place to v2 on next write.
|
|
36
|
+
Legacy `platforms` field at the version level is renamed to `ides`.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
40
|
+
data = json.load(f)
|
|
41
|
+
if data.get("schema_version") != "2":
|
|
42
|
+
data = _migrate_v1_to_v2(data)
|
|
43
|
+
data = _normalize_platforms_to_ides(data)
|
|
44
|
+
return data, None
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return {"schema_version": "2", "exports": {}, "updated_at": None}, None
|
|
47
|
+
except json.JSONDecodeError as e:
|
|
48
|
+
return None, f"Manifest JSON parse error: {e}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _migrate_v1_to_v2(data):
|
|
52
|
+
"""Migrate a v1 manifest to v2 format in memory."""
|
|
53
|
+
data["schema_version"] = "2"
|
|
54
|
+
for skill_name, entry in data.get("exports", {}).items():
|
|
55
|
+
versions = entry.get("versions", [])
|
|
56
|
+
if isinstance(versions, list):
|
|
57
|
+
active = entry.get("active_version", "")
|
|
58
|
+
deprecated = entry.get("deprecated", False)
|
|
59
|
+
new_versions = {}
|
|
60
|
+
for v in versions:
|
|
61
|
+
status = "active" if v == active and not deprecated else "archived"
|
|
62
|
+
new_versions[v] = {
|
|
63
|
+
"ides": [],
|
|
64
|
+
"last_exported": data.get("updated_at", ""),
|
|
65
|
+
"status": "deprecated" if deprecated and v == active else status,
|
|
66
|
+
}
|
|
67
|
+
entry["versions"] = new_versions
|
|
68
|
+
entry.pop("deprecated", None)
|
|
69
|
+
entry.pop("deprecated_versions", None)
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_platforms_to_ides(data):
|
|
74
|
+
"""Rename legacy `platforms` key → `ides` on each version entry.
|
|
75
|
+
|
|
76
|
+
Pre-rename v2 manifests used `platforms` for the list of IDE identifiers
|
|
77
|
+
the version was exported to. The field was ambiguous (IDE name vs context
|
|
78
|
+
file vs skill root), so it was renamed to `ides` to match
|
|
79
|
+
`config.yaml.ides`. Existing manifests are upgraded on next read.
|
|
80
|
+
"""
|
|
81
|
+
for entry in data.get("exports", {}).values():
|
|
82
|
+
versions = entry.get("versions", {})
|
|
83
|
+
if not isinstance(versions, dict):
|
|
84
|
+
continue
|
|
85
|
+
for v_data in versions.values():
|
|
86
|
+
if not isinstance(v_data, dict):
|
|
87
|
+
continue
|
|
88
|
+
if "platforms" in v_data:
|
|
89
|
+
legacy = v_data.pop("platforms")
|
|
90
|
+
if "ides" not in v_data:
|
|
91
|
+
v_data["ides"] = legacy
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_manifest(manifest_path, data):
|
|
96
|
+
"""Write manifest file atomically via write-to-temp-then-rename."""
|
|
97
|
+
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
98
|
+
manifest_path = Path(manifest_path)
|
|
99
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=manifest_path.parent, suffix=".tmp")
|
|
100
|
+
try:
|
|
101
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
102
|
+
json.dump(data, f, indent=2)
|
|
103
|
+
f.write("\n")
|
|
104
|
+
os.replace(tmp_path, manifest_path)
|
|
105
|
+
except Exception:
|
|
106
|
+
os.unlink(tmp_path)
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_read(manifest_path):
|
|
111
|
+
data, err = read_manifest(manifest_path)
|
|
112
|
+
if err:
|
|
113
|
+
return {"status": "error", "error": err}
|
|
114
|
+
return {"status": "ok", "manifest": data}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_get(manifest_path, skill_name):
|
|
118
|
+
data, err = read_manifest(manifest_path)
|
|
119
|
+
if err:
|
|
120
|
+
return {"status": "error", "error": err}
|
|
121
|
+
exports = data.get("exports", {})
|
|
122
|
+
if skill_name not in exports:
|
|
123
|
+
return {"status": "not_found", "skill": skill_name, "available": sorted(exports.keys())}
|
|
124
|
+
return {"status": "ok", "skill": skill_name, "entry": exports[skill_name]}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_set(manifest_path, skill_name, version):
|
|
128
|
+
data, err = read_manifest(manifest_path)
|
|
129
|
+
if err:
|
|
130
|
+
return {"status": "error", "error": err}
|
|
131
|
+
exports = data.setdefault("exports", {})
|
|
132
|
+
existing = exports.get(skill_name, {})
|
|
133
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
134
|
+
|
|
135
|
+
# Preserve existing versions dict, archive the previously-active version
|
|
136
|
+
versions = existing.get("versions", {})
|
|
137
|
+
if isinstance(versions, list):
|
|
138
|
+
versions = {} # Safety: handle any residual v1 data
|
|
139
|
+
old_active = existing.get("active_version")
|
|
140
|
+
if old_active and old_active in versions and old_active != version:
|
|
141
|
+
versions[old_active]["status"] = "archived"
|
|
142
|
+
|
|
143
|
+
# Add or update the new active version
|
|
144
|
+
existing_version = versions.get(version, {})
|
|
145
|
+
versions[version] = {
|
|
146
|
+
"ides": existing_version.get("ides", existing_version.get("platforms", [])),
|
|
147
|
+
"last_exported": today,
|
|
148
|
+
"status": "active",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
exports[skill_name] = {
|
|
152
|
+
"active_version": version,
|
|
153
|
+
"versions": versions,
|
|
154
|
+
}
|
|
155
|
+
write_manifest(manifest_path, data)
|
|
156
|
+
return {"status": "ok", "action": "set", "skill": skill_name, "version": version}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_remove(manifest_path, skill_name):
|
|
160
|
+
data, err = read_manifest(manifest_path)
|
|
161
|
+
if err:
|
|
162
|
+
return {"status": "error", "error": err}
|
|
163
|
+
exports = data.get("exports", {})
|
|
164
|
+
if skill_name not in exports:
|
|
165
|
+
return {"status": "not_found", "skill": skill_name}
|
|
166
|
+
removed = exports.pop(skill_name)
|
|
167
|
+
write_manifest(manifest_path, data)
|
|
168
|
+
return {"status": "ok", "action": "removed", "skill": skill_name, "removed_entry": removed}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_deprecate(manifest_path, skill_name, version=None):
|
|
172
|
+
data, err = read_manifest(manifest_path)
|
|
173
|
+
if err:
|
|
174
|
+
return {"status": "error", "error": err}
|
|
175
|
+
exports = data.get("exports", {})
|
|
176
|
+
if skill_name not in exports:
|
|
177
|
+
return {"status": "not_found", "skill": skill_name}
|
|
178
|
+
versions = exports[skill_name].get("versions", {})
|
|
179
|
+
if version:
|
|
180
|
+
if version not in versions:
|
|
181
|
+
return {"status": "error", "error": f"Version '{version}' not found for '{skill_name}'"}
|
|
182
|
+
versions[version]["status"] = "deprecated"
|
|
183
|
+
else:
|
|
184
|
+
# Deprecate all versions
|
|
185
|
+
for v in versions.values():
|
|
186
|
+
v["status"] = "deprecated"
|
|
187
|
+
write_manifest(manifest_path, data)
|
|
188
|
+
return {"status": "ok", "action": "deprecated", "skill": skill_name, "version": version}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_rename(manifest_path, old_name, new_name):
|
|
192
|
+
data, err = read_manifest(manifest_path)
|
|
193
|
+
if err:
|
|
194
|
+
return {"status": "error", "error": err}
|
|
195
|
+
exports = data.get("exports", {})
|
|
196
|
+
if old_name not in exports:
|
|
197
|
+
return {"status": "not_found", "skill": old_name}
|
|
198
|
+
if new_name in exports:
|
|
199
|
+
return {"status": "error", "error": f"Target name '{new_name}' already exists in manifest"}
|
|
200
|
+
exports[new_name] = exports.pop(old_name)
|
|
201
|
+
write_manifest(manifest_path, data)
|
|
202
|
+
return {"status": "ok", "action": "renamed", "from": old_name, "to": new_name}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main():
|
|
206
|
+
if len(sys.argv) < 3:
|
|
207
|
+
print("Usage: python3 skf-manifest-ops.py <skills-folder> <command> [args]", file=sys.stderr)
|
|
208
|
+
print("Commands: read, get <name>, set <name> <version>, remove <name>, deprecate <name> [version], rename <old> <new>", file=sys.stderr)
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
|
|
211
|
+
skills_folder = Path(sys.argv[1])
|
|
212
|
+
command = sys.argv[2]
|
|
213
|
+
manifest_path = skills_folder / ".export-manifest.json"
|
|
214
|
+
|
|
215
|
+
if command == "read":
|
|
216
|
+
result = cmd_read(manifest_path)
|
|
217
|
+
elif command == "get" and len(sys.argv) >= 4:
|
|
218
|
+
result = cmd_get(manifest_path, sys.argv[3])
|
|
219
|
+
elif command == "set" and len(sys.argv) >= 5:
|
|
220
|
+
result = cmd_set(manifest_path, sys.argv[3], sys.argv[4])
|
|
221
|
+
elif command == "remove" and len(sys.argv) >= 4:
|
|
222
|
+
result = cmd_remove(manifest_path, sys.argv[3])
|
|
223
|
+
elif command == "deprecate" and len(sys.argv) >= 4:
|
|
224
|
+
ver = sys.argv[4] if len(sys.argv) >= 5 else None
|
|
225
|
+
result = cmd_deprecate(manifest_path, sys.argv[3], ver)
|
|
226
|
+
elif command == "rename" and len(sys.argv) >= 5:
|
|
227
|
+
result = cmd_rename(manifest_path, sys.argv[3], sys.argv[4])
|
|
228
|
+
else:
|
|
229
|
+
result = {"status": "error", "error": f"Unknown command or missing args: {command}"}
|
|
230
|
+
|
|
231
|
+
print(json.dumps(result, indent=2))
|
|
232
|
+
sys.exit(0 if result["status"] == "ok" else 1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main()
|