bmad-module-skill-forge 2.1.0 → 3.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 +51 -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 +135 -18
- package/docs/verifying-a-skill.md +83 -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 +2 -2
- 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 +32 -12
- package/src/shared/references/description-guard-protocol.md +14 -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 +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- 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 +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- 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 +3 -2
- package/src/shared/scripts/skf-description-guard.py +117 -9
- package/src/shared/scripts/skf-detect-docs.py +892 -4
- 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 +1 -1
- package/src/shared/scripts/skf-detect-tools.py +215 -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 +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -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 +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -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 +1 -1
- 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 +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- 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 +128 -13
- 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 +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- 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 +4 -1
- 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 +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- 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 +27 -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/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- 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 +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- 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 +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- 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 +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +17 -6
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- 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 +107 -29
- 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 +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- 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 +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- 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 +55 -18
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
- 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 +79 -146
- 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 +118 -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 -89
- package/src/skf-setup/references/detect-and-tier.md +58 -66
- 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 +217 -254
- 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 +163 -88
- 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 +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- 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 +287 -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 +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -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 +127 -52
- 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 +233 -222
- 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 +250 -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 +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- 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 -645
- 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,49 +1,165 @@
|
|
|
1
1
|
# /// script
|
|
2
|
-
# requires-python = ">=3.
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# ///
|
|
5
|
-
"""SKF Resolve Package
|
|
5
|
+
"""SKF Resolve Package: read a quick-skill target, and resolve a package
|
|
6
|
+
name to its GitHub repository.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
`parse-target` splits what the user typed into its parts. `resolve` asks
|
|
9
|
+
every registry of the canonical chain, in this order:
|
|
9
10
|
|
|
10
11
|
1. npm registry (JavaScript/TypeScript)
|
|
11
12
|
2. PyPI registry (Python)
|
|
12
13
|
3. crates.io registry (Rust)
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
The first registry in that order that gives a GitHub repository is the
|
|
16
|
+
pick; every other registry that holds the name is listed beside it, so a
|
|
17
|
+
bare name such as `numpy`, which npm and PyPI both hold, never resolves to
|
|
18
|
+
one project without the other being named. Per-call timeout (default
|
|
19
|
+
10s); a timeout is treated as a soft failure, like a 404. Web-search
|
|
20
|
+
fallback is intentionally NOT in this helper: registries are
|
|
21
|
+
deterministic; web search is judgment, and stays in the LLM step
|
|
22
|
+
(`src/skf-quick-skill/references/registry-resolution.md`).
|
|
18
23
|
|
|
19
24
|
CLI:
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
uv run skf-resolve-package.py parse-target [--target <text>] [--local-first]
|
|
27
|
+
uv run skf-resolve-package.py resolve <package_name> \\
|
|
28
|
+
[--registry npm|pypi|crates] [--language <hint>] [--timeout 10]
|
|
29
|
+
|
|
30
|
+
parse-target reads the target from stdin when --target is not given, so a
|
|
31
|
+
target holding quotes or `$` reaches it unchanged. --local-first makes a
|
|
32
|
+
target that names an existing file or folder (relative to the working
|
|
33
|
+
folder, a leading `~` expanded) a local-path whatever its shape, before
|
|
34
|
+
any other shape is tried: `skill-brief.yaml`, `briefs/skill-brief.yaml`,
|
|
35
|
+
`libs/mylib` or a path with a space, which the shapes below read as a
|
|
36
|
+
package, a github `<owner>/<repo>` or unparsed text. The form earlier SKF
|
|
37
|
+
versions called, `skf-resolve-package.py <package_name> [--timeout 10]`,
|
|
38
|
+
still runs resolve.
|
|
39
|
+
|
|
40
|
+
parse-target output (stdout JSON; every key is always present, null when
|
|
41
|
+
the kind has none):
|
|
42
|
+
|
|
43
|
+
kind: "github" | "package" | "registry-page" | "other-host" |
|
|
44
|
+
"local-path" | "unparsed" (KINDS)
|
|
45
|
+
input: the target as read, without surrounding whitespace or
|
|
46
|
+
one pair of quotes, backticks or angle brackets
|
|
47
|
+
target_version: the version a trailing `@<version>` or `==<version>`
|
|
48
|
+
pins, or a registry page's version
|
|
49
|
+
dist_tag: package: the npm dist-tag a trailing `@<tag>` names
|
|
50
|
+
(`latest`, `canary`); it pins no version
|
|
51
|
+
owner, repo: github: the repository
|
|
52
|
+
url: github: https://github.com/<owner>/<repo>;
|
|
53
|
+
other-host: the URL as given
|
|
54
|
+
ref, subdir: github: the ref and the folder of a
|
|
55
|
+
`/tree/<ref>/<folder>` URL (the ref is one path part)
|
|
56
|
+
package_name: package, registry-page: the name to resolve
|
|
57
|
+
registry: "npm" | "pypi" | "crates" | null: the one registry
|
|
58
|
+
that can hold the name: a registry page's, PyPI for a
|
|
59
|
+
`==` pin, npm for a scoped `@scope/name`
|
|
60
|
+
skill_name: github, package, registry-page: the name the skill is
|
|
61
|
+
written under: the package name, else the last part
|
|
62
|
+
of a `/tree/` folder, else the repository name, in
|
|
63
|
+
lower case with each run of other characters turned
|
|
64
|
+
into one hyphen (`@babel/core` is `babel-core`,
|
|
65
|
+
`vercel/next.js` is `next-js`)
|
|
66
|
+
host: other-host: the host name, in lower case
|
|
67
|
+
path: local-path: the path as given
|
|
68
|
+
|
|
69
|
+
Shapes, in the order they are tried:
|
|
70
|
+
local-path starts with `.`, `/`, `\\`, `~`, a drive letter or
|
|
71
|
+
`file://`
|
|
72
|
+
package `<name>==<version>` (PyPI, extras dropped)
|
|
73
|
+
github an https, http, git, ssh or git+ URL of github.com
|
|
74
|
+
(www. included, `:` or a port after the host),
|
|
75
|
+
`github.com/<owner>/<repo>`,
|
|
76
|
+
`git@github.com:<owner>/<repo>`,
|
|
77
|
+
`github:<owner>/<repo>` or `<owner>/<repo>`; `.git`, a
|
|
78
|
+
trailing slash, a query, a fragment and any other path
|
|
79
|
+
after the repository are dropped
|
|
80
|
+
registry-page https://www.npmjs.com/package/<name>[/v/<version>],
|
|
81
|
+
https://pypi.org/project/<name>[/<version>] (or
|
|
82
|
+
pypi.python.org/pypi/...),
|
|
83
|
+
https://crates.io/crates/<name>[/<version>]; a page
|
|
84
|
+
tail that is no version (crates.io's `/versions`) is
|
|
85
|
+
dropped
|
|
86
|
+
other-host any other URL, `gitlab:` or `bitbucket:` shortcuts, or
|
|
87
|
+
`<host>/<path>` whose first part is a host name
|
|
88
|
+
package an npm, PyPI or crates.io name, `@scope/name` included
|
|
89
|
+
unparsed anything else: prose, an empty target
|
|
90
|
+
|
|
91
|
+
Every shape but local-path may end in `@<version>`: the text after the
|
|
92
|
+
last `@` (not the one that opens a scope) is the version when it is one
|
|
93
|
+
word of letters, digits, `.`, `_`, `+` and `-` that starts with a digit,
|
|
94
|
+
or with `v` and a digit (`0.5.0`, `v2.1.0-beta`). `==<version>` takes
|
|
95
|
+
the same word. After a package name, a word that starts with a letter
|
|
96
|
+
instead is an npm dist-tag.
|
|
97
|
+
|
|
98
|
+
resolve output (stdout JSON):
|
|
99
|
+
|
|
100
|
+
status: "ok" | "ambiguous" | "fallthrough" (STATUSES)
|
|
101
|
+
package_name: "<input>"
|
|
102
|
+
resolved_url: "https://github.com/<owner>/<repo>" (ok, ambiguous)
|
|
103
|
+
repo_owner: "<owner>" (ok, ambiguous)
|
|
104
|
+
repo_name: "<repo>" (ok, ambiguous)
|
|
105
|
+
skill_name: the package's skill name, as parse-target
|
|
106
|
+
writes it (ok, ambiguous)
|
|
107
|
+
registry_used: "npm" | "pypi" | "crates" (ok, ambiguous)
|
|
108
|
+
source_subdir: the package's folder in the repository, else null
|
|
109
|
+
(ok, ambiguous): npm's repository.directory, or the
|
|
110
|
+
folder of a `/tree/<ref>/<folder>` repository URL
|
|
111
|
+
also_found_in: every other registry that answered for the name, in
|
|
112
|
+
chain order (ok, ambiguous; empty for ok): {registry,
|
|
113
|
+
outcome, resolved_url, repo_owner, repo_name,
|
|
114
|
+
source_subdir}, the repository fields null unless its
|
|
115
|
+
outcome is ok
|
|
116
|
+
warning: the one-line warning a run that keeps the pick of an
|
|
117
|
+
ambiguous name records, `also_found_in: ...` (null
|
|
118
|
+
unless ambiguous)
|
|
119
|
+
name_found_in: the registries that answered for the name with
|
|
120
|
+
anything other than a 404 or a timeout (outcome ok,
|
|
121
|
+
no-github-link or error), in chain order
|
|
122
|
+
registries_tried: ["npm", ...]
|
|
32
123
|
registry_outcomes: {"npm": "ok|404|timeout|error|no-github-link", ...}
|
|
33
124
|
|
|
125
|
+
ok: one registry answered for the name, and it resolved it.
|
|
126
|
+
ambiguous: the pick resolved the name, and another registry of the
|
|
127
|
+
chain answered for it too (name_found_in holds both, and
|
|
128
|
+
also_found_in the others), so the name may belong to two
|
|
129
|
+
projects. The fields name the pick's repository; the
|
|
130
|
+
caller decides.
|
|
131
|
+
fallthrough: no registry gave a GitHub URL; the LLM step falls back to
|
|
132
|
+
web search.
|
|
133
|
+
|
|
134
|
+
An error (a registry that could not be read: an HTTP error other than
|
|
135
|
+
404, a refused connection, an unreadable answer) counts as an answer,
|
|
136
|
+
since that registry may still hold the name: while npm cannot be read,
|
|
137
|
+
a name PyPI resolves is ambiguous. A timeout does not count (#582's
|
|
138
|
+
rule, accepted): a registry that times out is left out of
|
|
139
|
+
name_found_in, and registry_outcomes shows the timeout.
|
|
140
|
+
|
|
141
|
+
--registry queries that registry alone. --language picks the registry
|
|
142
|
+
of a language hint (LANGUAGE_REGISTRIES, any letter case: javascript,
|
|
143
|
+
typescript, js and ts are npm, python and py PyPI, rust crates.io);
|
|
144
|
+
any other hint leaves the chain whole, and --registry wins over it. A
|
|
145
|
+
result from one registry is never ambiguous.
|
|
146
|
+
|
|
34
147
|
Exit codes:
|
|
35
148
|
|
|
36
|
-
0 status == "ok"
|
|
37
|
-
1 status == "fallthrough"
|
|
38
|
-
|
|
149
|
+
0 parse-target printed its result; resolve: status == "ok"
|
|
150
|
+
1 status == "fallthrough"
|
|
151
|
+
2 usage error (argparse)
|
|
152
|
+
3 status == "ambiguous"
|
|
39
153
|
"""
|
|
40
154
|
|
|
41
155
|
from __future__ import annotations
|
|
42
156
|
|
|
43
157
|
import argparse
|
|
44
158
|
import json
|
|
159
|
+
import os
|
|
45
160
|
import re
|
|
46
161
|
import socket
|
|
162
|
+
import string
|
|
47
163
|
import sys
|
|
48
164
|
import urllib.error
|
|
49
165
|
import urllib.parse
|
|
@@ -53,68 +169,292 @@ from typing import Optional
|
|
|
53
169
|
REGISTRY_TIMEOUT_SECONDS = 10.0
|
|
54
170
|
USER_AGENT = "skf-resolve-package/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
|
|
55
171
|
|
|
172
|
+
KINDS = ("github", "package", "registry-page", "other-host", "local-path", "unparsed")
|
|
173
|
+
REGISTRIES = ("npm", "pypi", "crates")
|
|
174
|
+
STATUSES = ("ok", "ambiguous", "fallthrough")
|
|
175
|
+
EXIT_CODES = {"ok": 0, "fallthrough": 1, "ambiguous": 3}
|
|
176
|
+
COMMANDS = ("parse-target", "resolve")
|
|
177
|
+
LANGUAGE_REGISTRIES = {"javascript": "npm", "typescript": "npm", "js": "npm", "ts": "npm",
|
|
178
|
+
"python": "pypi", "py": "pypi", "rust": "crates"}
|
|
179
|
+
# PyPI project_urls labels that name the repository, in the order they are
|
|
180
|
+
# read, as PEP 753 normalizes them; the homepage comes last.
|
|
181
|
+
PYPI_SOURCE_LABELS = ("source", "sourcecode", "repository", "github")
|
|
182
|
+
|
|
183
|
+
# skf-github-probe.py loads this file to read --repo with github_target:
|
|
184
|
+
# keep that name and what it takes and returns.
|
|
185
|
+
|
|
186
|
+
# A github.com URL: scheme URLs (userinfo, and a port or the scp-like `:`
|
|
187
|
+
# after the host, allowed), the scp-like ssh form and a URL without a
|
|
188
|
+
# scheme. The path is split by _github_location.
|
|
56
189
|
_GITHUB_URL_RE = re.compile(
|
|
57
|
-
r"https
|
|
190
|
+
r"^(?:(?:https?|git|ssh)://(?:[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?[/:]"
|
|
191
|
+
r"|git@github\.com:"
|
|
192
|
+
r"|(?:www\.)?github\.com/)"
|
|
193
|
+
r"(?P<path>\S*)$",
|
|
58
194
|
re.IGNORECASE,
|
|
59
195
|
)
|
|
196
|
+
# A GitHub owner or repository name: letters, digits, `-`, `_` and `.`,
|
|
197
|
+
# with at least one letter or digit.
|
|
198
|
+
_NAME_RE = re.compile(r"^(?=.*[A-Za-z0-9])[A-Za-z0-9_.-]+$")
|
|
199
|
+
# `<owner>/<repo>`: a GitHub owner has no dot, so `<host>/<path>` never matches.
|
|
200
|
+
_SHORTHAND_RE = re.compile(r"^(?P<owner>[A-Za-z0-9][A-Za-z0-9_-]*)/(?P<repo>[A-Za-z0-9_.-]+)$")
|
|
201
|
+
# A version starts with a digit, or with `v` and a digit; npm's dist-tags
|
|
202
|
+
# (`latest`, `canary`) start with a letter.
|
|
203
|
+
_VERSION_RE = re.compile(r"^[vV]?[0-9][A-Za-z0-9._+-]*$")
|
|
204
|
+
_DIST_TAG_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
|
|
205
|
+
_PACKAGE_RE = re.compile(r"^(?:@(?P<scope>[A-Za-z0-9][A-Za-z0-9._-]*)/)?[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
206
|
+
_PYPI_PIN_RE = re.compile(r"^(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==(?P<version>.*)$")
|
|
207
|
+
_SCHEME_RE = re.compile(r"^(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*)://")
|
|
208
|
+
_SCP_RE = re.compile(r"^[^@/\s:]+@(?P<host>[^:/\s]+):")
|
|
209
|
+
_HOST_RE = re.compile(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}(?::\d+)?$")
|
|
210
|
+
_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
211
|
+
|
|
212
|
+
_SHORTCUT_HOSTS = {"gitlab:": "gitlab.com", "bitbucket:": "bitbucket.org"}
|
|
213
|
+
_PAIRS = {'"': '"', "'": "'", "`": "`", "<": ">"}
|
|
214
|
+
_NPM_HOSTS = frozenset({"npmjs.com", "www.npmjs.com", "npmjs.org", "www.npmjs.org"})
|
|
215
|
+
_PYPI_HOSTS = frozenset({"pypi.org", "www.pypi.org"})
|
|
216
|
+
_CRATES_HOSTS = frozenset({"crates.io", "www.crates.io"})
|
|
217
|
+
_REGISTRY_HOSTS = _NPM_HOSTS | _PYPI_HOSTS | _CRATES_HOSTS | {"pypi.python.org"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------------------
|
|
221
|
+
# GitHub URLs
|
|
222
|
+
# --------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _clean_subdir(value) -> Optional[str]:
|
|
226
|
+
"""A repository-relative folder as `a/b`, or None when empty or leaving the repository."""
|
|
227
|
+
if not isinstance(value, str):
|
|
228
|
+
return None
|
|
229
|
+
parts = [p for p in value.replace("\\", "/").split("/") if p and p != "."]
|
|
230
|
+
if not parts or ".." in parts:
|
|
231
|
+
return None
|
|
232
|
+
return "/".join(parts)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _github_location(text: str) -> Optional[dict]:
|
|
236
|
+
"""owner, repo, ref and subdir of a github.com repository URL, else None.
|
|
237
|
+
|
|
238
|
+
ref and subdir come from a `/tree/<ref>/<folder>` tail; any other tail,
|
|
239
|
+
a query and a fragment are dropped.
|
|
240
|
+
"""
|
|
241
|
+
if not text or not isinstance(text, str):
|
|
242
|
+
return None
|
|
243
|
+
s = text.strip()
|
|
244
|
+
if s[:7].lower() == "github:": # npm's shortcut: github:<owner>/<repo>
|
|
245
|
+
s = "github.com/" + s[7:]
|
|
246
|
+
if s[:4].lower() == "git+":
|
|
247
|
+
s = s[4:]
|
|
248
|
+
m = _GITHUB_URL_RE.match(s)
|
|
249
|
+
if not m:
|
|
250
|
+
return None
|
|
251
|
+
path = re.split(r"[?#]", m.group("path"), maxsplit=1)[0]
|
|
252
|
+
parts = [urllib.parse.unquote(p) for p in path.split("/") if p]
|
|
253
|
+
if len(parts) < 2:
|
|
254
|
+
return None
|
|
255
|
+
owner, repo = parts[0], parts[1]
|
|
256
|
+
if repo.lower().endswith(".git"):
|
|
257
|
+
repo = repo[: -len(".git")]
|
|
258
|
+
if not all(_NAME_RE.fullmatch(p) for p in (owner, repo)) or owner.startswith("-"):
|
|
259
|
+
return None
|
|
260
|
+
ref = subdir = None
|
|
261
|
+
tail = parts[2:]
|
|
262
|
+
if len(tail) >= 2 and tail[0] == "tree":
|
|
263
|
+
ref, subdir = tail[1], _clean_subdir("/".join(tail[2:]))
|
|
264
|
+
return {"owner": owner, "repo": repo, "ref": ref, "subdir": subdir}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def github_target(text: str) -> Optional[dict]:
|
|
268
|
+
"""_github_location of a github.com URL or of `<owner>/<repo>`, else None."""
|
|
269
|
+
loc = _github_location(text)
|
|
270
|
+
if loc is None and isinstance(text, str) and _SHORTHAND_RE.fullmatch(text.strip()):
|
|
271
|
+
loc = _github_location(f"github.com/{text.strip()}")
|
|
272
|
+
return loc
|
|
60
273
|
|
|
61
274
|
|
|
62
275
|
def parse_github_url(url: str) -> Optional[tuple[str, str, str]]:
|
|
63
276
|
"""Parse a GitHub URL/string into (canonical_url, owner, repo).
|
|
64
277
|
|
|
65
|
-
Handles the variants registries actually emit
|
|
278
|
+
Handles the variants registries actually emit, dotted repository names
|
|
279
|
+
(`vercel/next.js`) included:
|
|
66
280
|
- https://github.com/owner/repo
|
|
67
281
|
- http://github.com/owner/repo
|
|
68
282
|
- github.com/owner/repo
|
|
69
283
|
- git+https://github.com/owner/repo.git
|
|
70
|
-
- git@github.com
|
|
284
|
+
- git://github.com/owner/repo.git, ssh://git@github.com/owner/repo.git
|
|
285
|
+
- git@github.com:owner/repo.git, ssh://git@github.com:owner/repo.git
|
|
71
286
|
- github:owner/repo (npm shortcut)
|
|
287
|
+
- any of these with a `/tree/<ref>/<folder>` or other path, a query
|
|
288
|
+
or a fragment after the repository
|
|
72
289
|
"""
|
|
73
|
-
|
|
290
|
+
loc = _github_location(url)
|
|
291
|
+
if loc is None:
|
|
74
292
|
return None
|
|
293
|
+
owner, repo = loc["owner"], loc["repo"]
|
|
294
|
+
return f"https://github.com/{owner}/{repo}", owner, repo
|
|
75
295
|
|
|
76
|
-
s = url.strip()
|
|
77
296
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
repo = repo.removesuffix(".git").rstrip("/")
|
|
83
|
-
if owner and repo:
|
|
84
|
-
return f"https://github.com/{owner}/{repo}", owner, repo
|
|
297
|
+
def _github_found(url: str, directory=None) -> Optional[tuple[str, str, str, Optional[str]]]:
|
|
298
|
+
"""(canonical_url, owner, repo, source_subdir) of a registry's repository URL, else None."""
|
|
299
|
+
loc = _github_location(url)
|
|
300
|
+
if loc is None:
|
|
85
301
|
return None
|
|
302
|
+
owner, repo = loc["owner"], loc["repo"]
|
|
303
|
+
return f"https://github.com/{owner}/{repo}", owner, repo, _clean_subdir(directory) or loc["subdir"]
|
|
86
304
|
|
|
87
|
-
if s.startswith("git+"):
|
|
88
|
-
s = s[len("git+") :]
|
|
89
305
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if "/" in rest:
|
|
94
|
-
owner, _, repo = rest.partition("/")
|
|
95
|
-
if owner and repo:
|
|
96
|
-
return f"https://github.com/{owner}/{repo}", owner, repo
|
|
97
|
-
return None
|
|
306
|
+
# --------------------------------------------------------------------------
|
|
307
|
+
# Targets
|
|
308
|
+
# --------------------------------------------------------------------------
|
|
98
309
|
|
|
99
|
-
if s.startswith("github.com/"):
|
|
100
|
-
s = "https://" + s
|
|
101
310
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
311
|
+
# Keep identical to _kebab in skf-skill-inventory.py (test/test-skf-resolve-package.py
|
|
312
|
+
# pins the copy): the inventory derives a skill's name with it.
|
|
313
|
+
def _kebab(segment) -> str:
|
|
314
|
+
"""Kebab-case, lowercase a single name segment (the skill-name rule).
|
|
315
|
+
|
|
316
|
+
Lowercase, drop a trailing ``.git``, collapse every run of non-alphanumeric
|
|
317
|
+
characters to a single hyphen, and strip leading / trailing hyphens.
|
|
318
|
+
"""
|
|
319
|
+
s = str(segment).strip().lower()
|
|
320
|
+
if s.endswith(".git"):
|
|
321
|
+
s = s[:-4]
|
|
322
|
+
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
323
|
+
return s.strip("-")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _split_at(text: str, word_re: re.Pattern) -> tuple[str, Optional[str]]:
|
|
327
|
+
"""(before, after) the last `@` (not a leading one) when word_re matches what follows it, else (text, None)."""
|
|
328
|
+
at = text.rfind("@")
|
|
329
|
+
if at > 0 and word_re.fullmatch(text[at + 1:]):
|
|
330
|
+
return text[:at], text[at + 1:]
|
|
331
|
+
return text, None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _is_local_path(text: str) -> bool:
|
|
335
|
+
return (text.startswith((".", "/", "\\", "~")) or bool(_DRIVE_RE.match(text))
|
|
336
|
+
or text[:7].lower() == "file://")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _on_disk(text: str) -> bool:
|
|
340
|
+
"""True when `text` names an existing file or folder, relative to the working folder, `~` expanded."""
|
|
341
|
+
path = text[7:] if text[:7].lower() == "file://" else text
|
|
342
|
+
return os.path.exists(os.path.expanduser(path)) # False for a name the OS refuses
|
|
106
343
|
|
|
344
|
+
|
|
345
|
+
def _url_host(text: str) -> Optional[tuple[str, str]]:
|
|
346
|
+
"""(host, path) of a URL-like target, else None."""
|
|
347
|
+
m = _SCHEME_RE.match(text)
|
|
348
|
+
if m:
|
|
349
|
+
authority, _, path = text[m.end():].partition("/")
|
|
350
|
+
host = authority.rpartition("@")[2].split(":")[0]
|
|
351
|
+
return (host.lower(), path) if host else None
|
|
352
|
+
m = _SCP_RE.match(text)
|
|
353
|
+
if m:
|
|
354
|
+
return m.group("host").lower(), text[m.end():]
|
|
355
|
+
first, sep, path = text.partition("/")
|
|
356
|
+
if sep and _HOST_RE.fullmatch(first):
|
|
357
|
+
return first.split(":")[0].lower(), path
|
|
107
358
|
return None
|
|
108
359
|
|
|
109
360
|
|
|
361
|
+
def _registry_page(host: str, path: str) -> Optional[tuple[str, str, Optional[str]]]:
|
|
362
|
+
"""(registry, package_name, version) of an npm, PyPI or crates.io package page, else None."""
|
|
363
|
+
parts = [urllib.parse.unquote(p) for p in re.split(r"[?#]", path, maxsplit=1)[0].split("/") if p]
|
|
364
|
+
if host in _NPM_HOSTS and len(parts) >= 2 and parts[0] == "package":
|
|
365
|
+
take = 3 if parts[1].startswith("@") else 2
|
|
366
|
+
name, rest = "/".join(parts[1:take]), parts[take:]
|
|
367
|
+
version = rest[1] if len(rest) >= 2 and rest[0] == "v" else None
|
|
368
|
+
registry = "npm"
|
|
369
|
+
elif len(parts) >= 2 and ((host in _PYPI_HOSTS and parts[0] == "project")
|
|
370
|
+
or (host == "pypi.python.org" and parts[0] == "pypi")):
|
|
371
|
+
name, version, registry = parts[1], (parts[2] if len(parts) >= 3 else None), "pypi"
|
|
372
|
+
elif host in _CRATES_HOSTS and len(parts) >= 2 and parts[0] == "crates":
|
|
373
|
+
name, version, registry = parts[1], (parts[2] if len(parts) >= 3 else None), "crates"
|
|
374
|
+
else:
|
|
375
|
+
return None
|
|
376
|
+
m = _PACKAGE_RE.fullmatch(name)
|
|
377
|
+
if not m or (m.group("scope") and registry != "npm"):
|
|
378
|
+
return None
|
|
379
|
+
if version is not None and not _VERSION_RE.fullmatch(version):
|
|
380
|
+
version = None
|
|
381
|
+
return registry, name, version
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def parse_target(text: str, local_first: bool = False) -> dict:
|
|
385
|
+
"""Split a quick-skill target into its kind and parts (see the module docstring)."""
|
|
386
|
+
raw = (text or "").strip()
|
|
387
|
+
if len(raw) >= 2 and raw[0] in _PAIRS and raw[-1] == _PAIRS[raw[0]]:
|
|
388
|
+
raw = raw[1:-1].strip()
|
|
389
|
+
out = {"kind": "unparsed", "input": raw, "target_version": None, "dist_tag": None, "owner": None,
|
|
390
|
+
"repo": None, "url": None, "ref": None, "subdir": None, "package_name": None, "registry": None,
|
|
391
|
+
"skill_name": None, "host": None, "path": None}
|
|
392
|
+
if raw and local_first and _on_disk(raw):
|
|
393
|
+
out.update(kind="local-path", path=raw[7:] if raw[:7].lower() == "file://" else raw)
|
|
394
|
+
return out
|
|
395
|
+
if not raw or any(ch.isspace() for ch in raw):
|
|
396
|
+
return out
|
|
397
|
+
if _is_local_path(raw):
|
|
398
|
+
out.update(kind="local-path", path=raw[7:] if raw[:7].lower() == "file://" else raw)
|
|
399
|
+
return out
|
|
400
|
+
|
|
401
|
+
pin = _PYPI_PIN_RE.fullmatch(raw)
|
|
402
|
+
if pin:
|
|
403
|
+
if _VERSION_RE.fullmatch(pin.group("version")):
|
|
404
|
+
out.update(kind="package", package_name=pin.group("name"), registry="pypi",
|
|
405
|
+
target_version=pin.group("version"), skill_name=_kebab(pin.group("name")))
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
body, version = _split_at(raw, _VERSION_RE)
|
|
409
|
+
loc = github_target(body)
|
|
410
|
+
if loc is not None:
|
|
411
|
+
folder = (loc["subdir"] or "").rpartition("/")[2]
|
|
412
|
+
out.update(kind="github", owner=loc["owner"], repo=loc["repo"], ref=loc["ref"], subdir=loc["subdir"],
|
|
413
|
+
url=f"https://github.com/{loc['owner']}/{loc['repo']}", target_version=version,
|
|
414
|
+
skill_name=_kebab(folder) or _kebab(loc["repo"]))
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
for prefix, host in _SHORTCUT_HOSTS.items():
|
|
418
|
+
if body[:len(prefix)].lower() == prefix:
|
|
419
|
+
out.update(kind="other-host", host=host, url=body, target_version=version)
|
|
420
|
+
return out
|
|
421
|
+
located = _url_host(body)
|
|
422
|
+
if located is not None:
|
|
423
|
+
host, path = located
|
|
424
|
+
if host in ("github.com", "www.github.com"):
|
|
425
|
+
return out # a github.com URL that names no repository
|
|
426
|
+
page = _registry_page(host, path)
|
|
427
|
+
if page is not None:
|
|
428
|
+
registry, name, page_version = page
|
|
429
|
+
out.update(kind="registry-page", registry=registry, package_name=name,
|
|
430
|
+
target_version=version or page_version, skill_name=_kebab(name))
|
|
431
|
+
elif host not in _REGISTRY_HOSTS: # a registry page that names no package stays unparsed
|
|
432
|
+
out.update(kind="other-host", host=host, url=body, target_version=version)
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
dist_tag = None
|
|
436
|
+
if version is None: # `next@canary`: an npm dist-tag, which pins no version
|
|
437
|
+
body, dist_tag = _split_at(body, _DIST_TAG_RE)
|
|
438
|
+
m = _PACKAGE_RE.fullmatch(body)
|
|
439
|
+
if m:
|
|
440
|
+
out.update(kind="package", package_name=body, registry="npm" if m.group("scope") else None,
|
|
441
|
+
target_version=version, dist_tag=dist_tag, skill_name=_kebab(body))
|
|
442
|
+
return out
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# --------------------------------------------------------------------------
|
|
446
|
+
# Registries
|
|
447
|
+
# --------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
|
|
110
450
|
def _http_get_json(url: str, timeout: float) -> tuple[Optional[dict], str]:
|
|
111
451
|
"""Fetch JSON. Returns (payload_or_None, outcome).
|
|
112
452
|
|
|
113
453
|
Outcome values:
|
|
114
|
-
"ok"
|
|
115
|
-
"404"
|
|
116
|
-
"timeout"
|
|
117
|
-
"error"
|
|
454
|
+
"ok" valid JSON object returned
|
|
455
|
+
"404" HTTP 404 (package does not exist on this registry)
|
|
456
|
+
"timeout" socket / urlopen timed out
|
|
457
|
+
"error" any other transport / parse error
|
|
118
458
|
"""
|
|
119
459
|
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
|
|
120
460
|
try:
|
|
@@ -135,56 +475,68 @@ def _http_get_json(url: str, timeout: float) -> tuple[Optional[dict], str]:
|
|
|
135
475
|
return None, "error"
|
|
136
476
|
|
|
137
477
|
|
|
138
|
-
|
|
139
|
-
|
|
478
|
+
# Each try_* returns ((canonical_url, owner, repo, source_subdir) or None, outcome).
|
|
479
|
+
# A folder comes only from a repository field, never from a homepage.
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def try_npm(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str, Optional[str]]], str]:
|
|
483
|
+
"""Try the npm registry. Returns (found_or_None, outcome)."""
|
|
140
484
|
encoded = urllib.parse.quote(package_name, safe="@")
|
|
141
485
|
url = f"https://registry.npmjs.org/{encoded}"
|
|
142
486
|
payload, outcome = _http_get_json(url, timeout)
|
|
143
487
|
if payload is None:
|
|
144
488
|
return None, outcome
|
|
145
|
-
candidates: list[str] = []
|
|
489
|
+
candidates: list[tuple[str, Optional[str], bool]] = [] # (url, directory, repository field)
|
|
146
490
|
repo = payload.get("repository")
|
|
147
491
|
if isinstance(repo, dict) and isinstance(repo.get("url"), str):
|
|
148
|
-
candidates.append(repo["url"])
|
|
492
|
+
candidates.append((repo["url"], repo.get("directory"), True))
|
|
149
493
|
elif isinstance(repo, str):
|
|
150
|
-
candidates.append(repo)
|
|
494
|
+
candidates.append((repo, None, True))
|
|
151
495
|
homepage = payload.get("homepage")
|
|
152
496
|
if isinstance(homepage, str):
|
|
153
|
-
candidates.append(homepage)
|
|
154
|
-
for c in candidates:
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
return
|
|
497
|
+
candidates.append((homepage, None, False))
|
|
498
|
+
for c, directory, is_repository in candidates:
|
|
499
|
+
found = _github_found(c, directory)
|
|
500
|
+
if found:
|
|
501
|
+
return (found if is_repository else (*found[:3], None)), "ok"
|
|
158
502
|
return None, "no-github-link"
|
|
159
503
|
|
|
160
504
|
|
|
161
|
-
def
|
|
162
|
-
"""
|
|
505
|
+
def _pypi_label(key: str) -> str:
|
|
506
|
+
"""A project_urls label as PEP 753 normalizes it: punctuation and whitespace removed, lower case."""
|
|
507
|
+
return "".join(ch for ch in key if ch not in string.punctuation and not ch.isspace()).lower()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def try_pypi(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str, Optional[str]]], str]:
|
|
511
|
+
"""Try the PyPI registry. Returns (found_or_None, outcome)."""
|
|
163
512
|
encoded = urllib.parse.quote(package_name, safe="")
|
|
164
513
|
url = f"https://pypi.org/pypi/{encoded}/json"
|
|
165
514
|
payload, outcome = _http_get_json(url, timeout)
|
|
166
515
|
if payload is None:
|
|
167
516
|
return None, outcome
|
|
168
517
|
info = payload.get("info") or {}
|
|
169
|
-
candidates: list[str] = []
|
|
518
|
+
candidates: list[tuple[str, bool]] = [] # (url, repository field)
|
|
170
519
|
project_urls = info.get("project_urls") or {}
|
|
520
|
+
labels: dict[str, str] = {} # normalized label -> the first URL under it
|
|
171
521
|
if isinstance(project_urls, dict):
|
|
172
|
-
for key in (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
522
|
+
for key, v in project_urls.items():
|
|
523
|
+
if isinstance(key, str) and isinstance(v, str):
|
|
524
|
+
labels.setdefault(_pypi_label(key), v)
|
|
525
|
+
for label in (*PYPI_SOURCE_LABELS, "homepage"):
|
|
526
|
+
if label in labels:
|
|
527
|
+
candidates.append((labels[label], label != "homepage"))
|
|
176
528
|
home_page = info.get("home_page")
|
|
177
529
|
if isinstance(home_page, str):
|
|
178
|
-
candidates.append(home_page)
|
|
179
|
-
for c in candidates:
|
|
180
|
-
|
|
181
|
-
if
|
|
182
|
-
return
|
|
530
|
+
candidates.append((home_page, False))
|
|
531
|
+
for c, is_repository in candidates:
|
|
532
|
+
found = _github_found(c)
|
|
533
|
+
if found:
|
|
534
|
+
return (found if is_repository else (*found[:3], None)), "ok"
|
|
183
535
|
return None, "no-github-link"
|
|
184
536
|
|
|
185
537
|
|
|
186
|
-
def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
|
|
187
|
-
"""Try the crates.io registry. Returns (
|
|
538
|
+
def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str, Optional[str]]], str]:
|
|
539
|
+
"""Try the crates.io registry. Returns (found_or_None, outcome)."""
|
|
188
540
|
encoded = urllib.parse.quote(package_name, safe="")
|
|
189
541
|
url = f"https://crates.io/api/v1/crates/{encoded}"
|
|
190
542
|
payload, outcome = _http_get_json(url, timeout)
|
|
@@ -194,9 +546,9 @@ def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, s
|
|
|
194
546
|
for key in ("repository", "homepage"):
|
|
195
547
|
v = crate.get(key)
|
|
196
548
|
if isinstance(v, str):
|
|
197
|
-
|
|
198
|
-
if
|
|
199
|
-
return
|
|
549
|
+
found = _github_found(v)
|
|
550
|
+
if found:
|
|
551
|
+
return (found if key == "repository" else (*found[:3], None)), "ok"
|
|
200
552
|
return None, "no-github-link"
|
|
201
553
|
|
|
202
554
|
|
|
@@ -207,57 +559,122 @@ _RESOLVER_NAMES: tuple[tuple[str, str], ...] = (
|
|
|
207
559
|
)
|
|
208
560
|
|
|
209
561
|
|
|
210
|
-
|
|
562
|
+
# How the also_found_in warning names a registry that gave no repository.
|
|
563
|
+
_NO_REPOSITORY = {"no-github-link": "no GitHub repository", "error": "could not be read"}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _also_found_warning(package_name: str, registry_used: str, url: str, also: list[dict]) -> str:
|
|
567
|
+
"""The one line a run that keeps the pick of an ambiguous name records as a warning."""
|
|
568
|
+
others = ", ".join(f"{entry['registry']} ({entry['resolved_url'] or _NO_REPOSITORY[entry['outcome']]})"
|
|
569
|
+
for entry in also)
|
|
570
|
+
return f"also_found_in: {package_name} resolved on {registry_used} ({url}); also on {others}"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def resolve_package(package_name: str, timeout: float = REGISTRY_TIMEOUT_SECONDS,
|
|
574
|
+
registry: Optional[str] = None, language: Optional[str] = None) -> dict:
|
|
575
|
+
"""Ask every registry of the chain, or only `registry` or the language's (see the module docstring)."""
|
|
576
|
+
registry = registry or LANGUAGE_REGISTRIES.get((language or "").strip().lower())
|
|
211
577
|
registries_tried: list[str] = []
|
|
212
578
|
outcomes: dict[str, str] = {}
|
|
579
|
+
found: dict[str, tuple[str, str, str, Optional[str]]] = {}
|
|
213
580
|
|
|
214
581
|
# Dynamic lookup so test code can monkeypatch try_npm / try_pypi / try_crates
|
|
215
582
|
# without re-binding entries in a module-level tuple of function refs.
|
|
216
583
|
for name, fn_name in _RESOLVER_NAMES:
|
|
584
|
+
if registry is not None and name != registry:
|
|
585
|
+
continue
|
|
217
586
|
registries_tried.append(name)
|
|
218
|
-
|
|
219
|
-
result, outcome = fn(package_name, timeout)
|
|
220
|
-
outcomes[name] = outcome
|
|
587
|
+
result, outcomes[name] = globals()[fn_name](package_name, timeout)
|
|
221
588
|
if result is not None:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
589
|
+
found[name] = result
|
|
590
|
+
name_found_in = [n for n in registries_tried if outcomes[n] not in ("404", "timeout")]
|
|
591
|
+
picked = next((n for n in registries_tried if n in found), None)
|
|
592
|
+
if picked is None:
|
|
593
|
+
return {
|
|
594
|
+
"status": "fallthrough",
|
|
595
|
+
"package_name": package_name,
|
|
596
|
+
"name_found_in": name_found_in,
|
|
597
|
+
"registries_tried": registries_tried,
|
|
598
|
+
"registry_outcomes": outcomes,
|
|
599
|
+
}
|
|
600
|
+
url, owner, repo, subdir = found[picked]
|
|
601
|
+
also = []
|
|
602
|
+
for name in name_found_in:
|
|
603
|
+
if name != picked:
|
|
604
|
+
other = found.get(name) or (None, None, None, None)
|
|
605
|
+
also.append({"registry": name, "outcome": outcomes[name], "resolved_url": other[0],
|
|
606
|
+
"repo_owner": other[1], "repo_name": other[2], "source_subdir": other[3]})
|
|
234
607
|
return {
|
|
235
|
-
"status": "
|
|
608
|
+
"status": "ambiguous" if also else "ok",
|
|
236
609
|
"package_name": package_name,
|
|
610
|
+
"resolved_url": url,
|
|
611
|
+
"repo_owner": owner,
|
|
612
|
+
"repo_name": repo,
|
|
613
|
+
"skill_name": _kebab(package_name),
|
|
614
|
+
"registry_used": picked,
|
|
615
|
+
"source_subdir": subdir,
|
|
616
|
+
"also_found_in": also,
|
|
617
|
+
"warning": _also_found_warning(package_name, picked, url, also) if also else None,
|
|
618
|
+
"name_found_in": name_found_in,
|
|
237
619
|
"registries_tried": registries_tried,
|
|
238
620
|
"registry_outcomes": outcomes,
|
|
239
621
|
}
|
|
240
622
|
|
|
241
623
|
|
|
242
|
-
|
|
624
|
+
# --------------------------------------------------------------------------
|
|
625
|
+
# CLI
|
|
626
|
+
# --------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _read_stdin() -> str:
|
|
630
|
+
"""stdin as UTF-8, whatever the locale's code page (cp1252 on Windows)."""
|
|
631
|
+
stream = getattr(sys.stdin, "buffer", None)
|
|
632
|
+
if stream is not None:
|
|
633
|
+
return stream.read().decode("utf-8", errors="replace")
|
|
634
|
+
return sys.stdin.read()
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
243
638
|
parser = argparse.ArgumentParser(
|
|
244
|
-
|
|
639
|
+
prog="skf-resolve-package",
|
|
640
|
+
description="Read a quick-skill target, or resolve a package name to a GitHub repository "
|
|
641
|
+
"via npm/PyPI/crates.io.",
|
|
245
642
|
)
|
|
246
|
-
parser.
|
|
643
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
644
|
+
p_parse = sub.add_parser("parse-target", help="split a target into its kind, name, version and repository")
|
|
645
|
+
p_parse.add_argument("--target", help="the target as typed (default: read from stdin)")
|
|
646
|
+
p_parse.add_argument("--local-first", action="store_true",
|
|
647
|
+
help="a target that names an existing file or folder is a local-path, whatever its shape")
|
|
648
|
+
p_resolve = sub.add_parser("resolve", help="resolve a package name to its GitHub repository")
|
|
649
|
+
p_resolve.add_argument(
|
|
247
650
|
"package_name",
|
|
248
|
-
help="Package name to resolve (e.g., lodash, @tanstack/query, requests, serde).",
|
|
651
|
+
help="Package name to resolve (e.g., lodash, @tanstack/react-query, requests, serde).",
|
|
249
652
|
)
|
|
250
|
-
|
|
653
|
+
p_resolve.add_argument("--registry", choices=REGISTRIES, help="query only this registry")
|
|
654
|
+
p_resolve.add_argument("--language",
|
|
655
|
+
help="a language hint: javascript, typescript, python or rust queries only its "
|
|
656
|
+
"registry (--registry wins); any other language queries them all")
|
|
657
|
+
p_resolve.add_argument(
|
|
251
658
|
"--timeout",
|
|
252
659
|
type=float,
|
|
253
660
|
default=REGISTRY_TIMEOUT_SECONDS,
|
|
254
661
|
help=f"Per-registry timeout in seconds (default: {REGISTRY_TIMEOUT_SECONDS}).",
|
|
255
662
|
)
|
|
256
|
-
|
|
663
|
+
return parser
|
|
664
|
+
|
|
257
665
|
|
|
258
|
-
|
|
666
|
+
def main(argv: list[str]) -> int:
|
|
667
|
+
if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
|
|
668
|
+
argv = ["resolve", *argv] # the form before the subcommands: <package_name> [--timeout N]
|
|
669
|
+
args = _build_parser().parse_args(argv)
|
|
670
|
+
if args.cmd == "parse-target":
|
|
671
|
+
text = args.target if args.target is not None else _read_stdin()
|
|
672
|
+
print(json.dumps(parse_target(text, local_first=args.local_first), indent=2))
|
|
673
|
+
return 0
|
|
674
|
+
result = resolve_package(args.package_name, timeout=args.timeout, registry=args.registry,
|
|
675
|
+
language=args.language)
|
|
259
676
|
print(json.dumps(result, indent=2))
|
|
260
|
-
return
|
|
677
|
+
return EXIT_CODES[result["status"]]
|
|
261
678
|
|
|
262
679
|
|
|
263
680
|
if __name__ == "__main__":
|