skillctl-secure 0.28.2__tar.gz → 0.29.0__tar.gz

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.
Files changed (128) hide show
  1. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/PKG-INFO +69 -1
  2. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/README.md +68 -0
  3. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/pyproject.toml +1 -1
  4. skillctl_secure-0.29.0/skillctl/__init__.py +1 -0
  5. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/compatibility.py +15 -2
  6. skillctl_secure-0.29.0/skillctl/destinations.py +324 -0
  7. skillctl_secure-0.29.0/skillctl/destinations_cli.py +67 -0
  8. skillctl_secure-0.29.0/skillctl/destinations_tui.py +214 -0
  9. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/entrypoint.py +1 -0
  10. skillctl_secure-0.29.0/skillctl/findings_tui.py +108 -0
  11. skillctl_secure-0.29.0/skillctl/inventory.py +195 -0
  12. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/manager.py +2 -0
  13. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/recommendation_tui.py +2 -15
  14. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/section_navigation.py +1 -1
  15. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_interaction.py +13 -2
  16. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_original.py +46 -69
  17. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_progressive.py +2 -1
  18. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_refresh.py +23 -3
  19. skillctl_secure-0.28.2/skillctl/__init__.py +0 -1
  20. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/.gitignore +0 -0
  21. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/LICENSE +0 -0
  22. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/__main__.py +0 -0
  23. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/authcontrols.py +0 -0
  24. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/benchmark.py +0 -0
  25. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/browsercontrols.py +0 -0
  26. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_cli.py +0 -0
  27. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_plan.py +0 -0
  28. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_update.py +0 -0
  29. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_update_tui.py +0 -0
  30. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cacheutil.py +0 -0
  31. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/candidates.py +0 -0
  32. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog.py +0 -0
  33. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_discovery.py +0 -0
  34. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_index.py +0 -0
  35. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_schema.py +0 -0
  36. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_search_cli.py +0 -0
  37. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_search_tui.py +0 -0
  38. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cicdflow.py +0 -0
  39. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cli.py +0 -0
  40. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cli_contract.py +0 -0
  41. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/context_cost.py +0 -0
  42. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/context_tui.py +0 -0
  43. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/copilot_export.py +0 -0
  44. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/coverage.py +0 -0
  45. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/dedupe.py +0 -0
  46. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/doctor.py +0 -0
  47. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/environments.py +0 -0
  48. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/estimate.py +0 -0
  49. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/evidence.py +0 -0
  50. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/findings.py +0 -0
  51. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/flow.py +0 -0
  52. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/governor.py +0 -0
  53. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/hardening.py +0 -0
  54. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/history_tui.py +0 -0
  55. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/iac.py +0 -0
  56. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/install_intent.py +0 -0
  57. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/install_review.py +0 -0
  58. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/intent_diff.py +0 -0
  59. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/journal.py +0 -0
  60. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/journal_hooks.py +0 -0
  61. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/journal_operations.py +0 -0
  62. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/lockfile.py +0 -0
  63. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/models.py +0 -0
  64. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/navigation_tui.py +0 -0
  65. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/operation_lock.py +0 -0
  66. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/packet.py +0 -0
  67. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/paths.py +0 -0
  68. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/permissions.py +0 -0
  69. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/policy.py +0 -0
  70. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/policy_editor.py +0 -0
  71. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/precision.py +0 -0
  72. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/profiles.py +0 -0
  73. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/publishers.py +0 -0
  74. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/ranking.py +0 -0
  75. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/recommendation.py +0 -0
  76. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_cli.py +0 -0
  77. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_recovery.py +0 -0
  78. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_transaction.py +0 -0
  79. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_tui.py +0 -0
  80. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconciliation.py +0 -0
  81. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/remote.py +0 -0
  82. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/answer-key.yaml +0 -0
  83. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/.env +0 -0
  84. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/.github/workflows/build.yml +0 -0
  85. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/README.md +0 -0
  86. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/app.py +0 -0
  87. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/openapi.yaml +0 -0
  88. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/requirements.txt +0 -0
  89. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/scripts/build.sh +0 -0
  90. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/templates/payment.html +0 -0
  91. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/trivy-results.json +0 -0
  92. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/.gitkeep +0 -0
  93. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/api-security-review/SKILL.md +0 -0
  94. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/api-security-review/references/inventory-template.md +0 -0
  95. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/api-security-review/references/output-template.md +0 -0
  96. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/SKILL.md +0 -0
  97. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/disposition.md +0 -0
  98. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/orchestration.md +0 -0
  99. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/output-template.md +0 -0
  100. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/review-checklist.md +0 -0
  101. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/risk-rating.md +0 -0
  102. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/cicd-security-review/SKILL.md +0 -0
  103. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/cicd-security-review/references/output-template.md +0 -0
  104. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/pci-architect/SKILL.md +0 -0
  105. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/pci-architect/references/control-matrix.md +0 -0
  106. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/pci-architect/references/output-template.md +0 -0
  107. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/threat-model/SKILL.md +0 -0
  108. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/threat-model/references/output-template.md +0 -0
  109. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/threat-model/references/risk-rating.md +0 -0
  110. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/SKILL.md +0 -0
  111. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/references/escalation.md +0 -0
  112. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/references/output-template.md +0 -0
  113. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/references/prioritization.md +0 -0
  114. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/finding-schema.json +0 -0
  115. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/skills.yaml +0 -0
  116. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/review.py +0 -0
  117. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/review_candidates.py +0 -0
  118. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/risk_acknowledgment.py +0 -0
  119. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/runtime_binding.py +0 -0
  120. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/safety_plan.py +0 -0
  121. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/stagecache.py +0 -0
  122. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/stateio.py +0 -0
  123. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/transactions.py +0 -0
  124. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/trust.py +0 -0
  125. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui.py +0 -0
  126. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/update_intent_review.py +0 -0
  127. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/update_intent_tui.py +0 -0
  128. {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/vulnnormalize.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: skillctl-secure
3
- Version: 0.28.2
3
+ Version: 0.29.0
4
4
  Summary: Package manager, trust layer, and portability layer for Agent Skills
5
5
  Project-URL: Homepage, https://github.com/brian-scardina/skillctl
6
6
  Project-URL: Repository, https://github.com/brian-scardina/skillctl
@@ -310,3 +310,71 @@ recovery or schema error.
310
310
  - [Environment reconciliation](docs/RECONCILIATION.md)
311
311
  - [Upgrade and persisted-format guidance](docs/UPGRADES.md)
312
312
  - [CLI/API contract](docs/CLI-CONTRACT.md)
313
+
314
+ ### Choose where a skill is installed
315
+
316
+ In the TUI, select a skill and choose **Install…**, including when the skill
317
+ is already installed in the active toolbox. Select one or more destinations,
318
+ choose **Review selection**, inspect the source findings and runtime requirements,
319
+ and choose **Apply selected**. **Update** and **Uninstall** also let you select
320
+ which destinations to change. **Update all** applies only to the active toolbox.
321
+
322
+ The header identifies the current **Toolbox**, which is an isolated skillctl skill
323
+ set. It is separate from an AI app destination. Skill details show the destination
324
+ paths and local status. **Installed locally** means validated files exist; it does
325
+ not establish that an AI session has discovered or executed them. Detection of an
326
+ app/configuration is also separate from discovery of a skill inside the app.
327
+
328
+ The selector offers personal destinations for Codex, GitHub Copilot, and Claude
329
+ Code. Use **Add destination** for a project or another local installation, specifying
330
+ the app and its actual skill directory. For example, use `/repo/.github/skills`
331
+ for a GitHub Copilot project. Shared directories are written once; removing a
332
+ shared copy can affect every app reading that directory. Unmanaged or locally
333
+ modified files are protected from replacement and removal.
334
+
335
+ **Microsoft 365 Copilot Cowork** is a separate destination from GitHub Copilot and
336
+ ordinary Copilot Chat. The installer prepares ZIP packages with `SKILL.md` at the
337
+ archive root. In Cowork, use **Customize → Skills → Add → Upload skill**, including
338
+ packages for required dependencies. Status remains **Import required**; local
339
+ packaging does not confirm import or runtime compatibility. Test in a new
340
+ conversation. Removing the local export does not remove the imported Cowork skill.
341
+ See [Microsoft's Cowork import documentation](https://learn.microsoft.com/en-us/microsoft-365/copilot/cowork/cowork-customize).
342
+
343
+ CLI equivalents:
344
+
345
+ ```bash
346
+ skillctl destinations archify
347
+ skillctl install-to archify --target codex --target github-copilot
348
+ skillctl install-to archify --target codex --target github-copilot --apply
349
+ skillctl install-to archify --target github-copilot --operation update --apply
350
+ skillctl install-to archify --target github-copilot --operation remove --apply
351
+ ```
352
+
353
+ Without `--apply`, `install-to` only reviews the selected destinations. REVIEW
354
+ items additionally require `--approve-review`; DENY items remain blocked.
355
+ Operations complete independently by destination, so inspect each result when
356
+ some destinations fail. Existing `install` and collection commands continue to
357
+ use the active toolbox.
358
+
359
+ ### Discover skills already on this computer
360
+
361
+ Startup and **Ctrl+R** scan known local skill directories automatically. Open
362
+ **Skills → Inventory** (or the inventory row on Home) to see skills installed by
363
+ skillctl, another installer, or manual copying—even when they are absent from the
364
+ source catalog. **Installed here** remains scoped to the active toolbox.
365
+
366
+ Inventory shows each physical copy's app destinations, paths, description,
367
+ version when available, and ownership. Shared directories and linked aliases are
368
+ grouped; independent copies remain separate. Invalid metadata, unreadable roots,
369
+ and missing files with an installation record are reported. Audit-only catalog
370
+ records do not count as installed skills.
371
+
372
+ Discovery reads bounded `SKILL.md` metadata from supported personal directories,
373
+ Codex's system-skill directory, the launch directory's standard project skill
374
+ folders, registered project destinations, and named toolboxes. It does not walk
375
+ the entire disk, infer installed plugins from cached versions, execute skill
376
+ instructions, or take ownership of external files. Cloud/account imports and
377
+ actual discovery inside an AI session remain unverified.
378
+
379
+ Use `skillctl inventory` or `skillctl inventory --json` for the same discovery from
380
+ the command line. JSON output also includes scan time, checked roots, and errors.
@@ -286,3 +286,71 @@ recovery or schema error.
286
286
  - [Environment reconciliation](docs/RECONCILIATION.md)
287
287
  - [Upgrade and persisted-format guidance](docs/UPGRADES.md)
288
288
  - [CLI/API contract](docs/CLI-CONTRACT.md)
289
+
290
+ ### Choose where a skill is installed
291
+
292
+ In the TUI, select a skill and choose **Install…**, including when the skill
293
+ is already installed in the active toolbox. Select one or more destinations,
294
+ choose **Review selection**, inspect the source findings and runtime requirements,
295
+ and choose **Apply selected**. **Update** and **Uninstall** also let you select
296
+ which destinations to change. **Update all** applies only to the active toolbox.
297
+
298
+ The header identifies the current **Toolbox**, which is an isolated skillctl skill
299
+ set. It is separate from an AI app destination. Skill details show the destination
300
+ paths and local status. **Installed locally** means validated files exist; it does
301
+ not establish that an AI session has discovered or executed them. Detection of an
302
+ app/configuration is also separate from discovery of a skill inside the app.
303
+
304
+ The selector offers personal destinations for Codex, GitHub Copilot, and Claude
305
+ Code. Use **Add destination** for a project or another local installation, specifying
306
+ the app and its actual skill directory. For example, use `/repo/.github/skills`
307
+ for a GitHub Copilot project. Shared directories are written once; removing a
308
+ shared copy can affect every app reading that directory. Unmanaged or locally
309
+ modified files are protected from replacement and removal.
310
+
311
+ **Microsoft 365 Copilot Cowork** is a separate destination from GitHub Copilot and
312
+ ordinary Copilot Chat. The installer prepares ZIP packages with `SKILL.md` at the
313
+ archive root. In Cowork, use **Customize → Skills → Add → Upload skill**, including
314
+ packages for required dependencies. Status remains **Import required**; local
315
+ packaging does not confirm import or runtime compatibility. Test in a new
316
+ conversation. Removing the local export does not remove the imported Cowork skill.
317
+ See [Microsoft's Cowork import documentation](https://learn.microsoft.com/en-us/microsoft-365/copilot/cowork/cowork-customize).
318
+
319
+ CLI equivalents:
320
+
321
+ ```bash
322
+ skillctl destinations archify
323
+ skillctl install-to archify --target codex --target github-copilot
324
+ skillctl install-to archify --target codex --target github-copilot --apply
325
+ skillctl install-to archify --target github-copilot --operation update --apply
326
+ skillctl install-to archify --target github-copilot --operation remove --apply
327
+ ```
328
+
329
+ Without `--apply`, `install-to` only reviews the selected destinations. REVIEW
330
+ items additionally require `--approve-review`; DENY items remain blocked.
331
+ Operations complete independently by destination, so inspect each result when
332
+ some destinations fail. Existing `install` and collection commands continue to
333
+ use the active toolbox.
334
+
335
+ ### Discover skills already on this computer
336
+
337
+ Startup and **Ctrl+R** scan known local skill directories automatically. Open
338
+ **Skills → Inventory** (or the inventory row on Home) to see skills installed by
339
+ skillctl, another installer, or manual copying—even when they are absent from the
340
+ source catalog. **Installed here** remains scoped to the active toolbox.
341
+
342
+ Inventory shows each physical copy's app destinations, paths, description,
343
+ version when available, and ownership. Shared directories and linked aliases are
344
+ grouped; independent copies remain separate. Invalid metadata, unreadable roots,
345
+ and missing files with an installation record are reported. Audit-only catalog
346
+ records do not count as installed skills.
347
+
348
+ Discovery reads bounded `SKILL.md` metadata from supported personal directories,
349
+ Codex's system-skill directory, the launch directory's standard project skill
350
+ folders, registered project destinations, and named toolboxes. It does not walk
351
+ the entire disk, infer installed plugins from cached versions, execute skill
352
+ instructions, or take ownership of external files. Cloud/account imports and
353
+ actual discovery inside an AI session remain unverified.
354
+
355
+ Use `skillctl inventory` or `skillctl inventory --json` for the same discovery from
356
+ the command line. JSON output also includes scan time, checked roots, and errors.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skillctl-secure"
3
- version = "0.28.2"
3
+ version = "0.29.0"
4
4
  description = "Package manager, trust layer, and portability layer for Agent Skills"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1 @@
1
+ __version__ = "0.29.0"
@@ -34,9 +34,13 @@ HOSTS: dict[str, HostSpec] = {
34
34
  "Agent Skills-compatible format; some skills may depend on Claude-specific tools.",
35
35
  ),
36
36
  "microsoft-365-copilot": HostSpec(
37
- "microsoft-365-copilot", "Microsoft 365 Copilot", False,
37
+ "microsoft-365-copilot", "Microsoft 365 Copilot (Chat / Studio adapter)", False,
38
38
  (), (),
39
- "No local Agent Skills discovery path; explicit export/adapter required.",
39
+ "Chat does not discover local skill directories. Studio uses the legacy adaptation export; Cowork is a separate import target.",
40
+ ),
41
+ "copilot-cowork": HostSpec(
42
+ "copilot-cowork", "Microsoft 365 Copilot Cowork", True, (), (),
43
+ "Import SKILL.md or a ZIP through Customize → Skills → Upload skill; files are saved to OneDrive. Local file placement is not app discovery.",
40
44
  ),
41
45
  }
42
46
 
@@ -222,6 +226,15 @@ def evaluate_skill(skill_dir: Path | None, host_id: str, publisher_note: str = "
222
226
  host = HOSTS[host_id]
223
227
  observed = collect_evidence(skill_dir, publisher_note)
224
228
 
229
+ if host_id == "copilot-cowork":
230
+ return {
231
+ "host": host_id, "host_name": host.name, "format": "import-required",
232
+ "runtime": "unknown", "confidence": "medium",
233
+ "reasons": ["Explicit Cowork skill import is required; ordinary Copilot Chat is a separate surface.",
234
+ "Review companion scripts, tools, connectors, and credentials before use. Import does not establish runtime compatibility."],
235
+ **observed,
236
+ }
237
+
225
238
  if not host.agent_skills:
226
239
  return {
227
240
  "host": host_id,
@@ -0,0 +1,324 @@
1
+ """Explicit destinations, independent of the active skillctl toolbox.
2
+
3
+ Discovery is filesystem/CLI evidence, never proof that an AI session loaded a skill.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from copy import copy, deepcopy
8
+ from dataclasses import dataclass, field, replace
9
+ import hashlib
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import threading
15
+ import zipfile
16
+
17
+ from .bulk_update import UpdateBatch, UpdateItem, _scope, apply_updates
18
+ from .lockfile import Lockfile
19
+ from .operation_lock import exclusive_operation
20
+ from .stateio import load_json_state, save_json_state
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Destination:
25
+ id: str
26
+ label: str
27
+ root: Path
28
+ host: str = ""
29
+ mode: str = "local"
30
+ detected: bool = False
31
+ scope: str = "Personal"
32
+
33
+
34
+ def registry_path():
35
+ return Path.home() / ".skillctl" / "destinations.json"
36
+
37
+
38
+ def known_destinations(manager, name=None):
39
+ home = Path.home()
40
+ options = [Destination("toolbox", f"Toolbox: {manager.active_environment}", manager.install_dir, detected=True, scope="Toolbox")]
41
+ for key, label, folder, command in (
42
+ ("codex", "OpenAI Codex", ".agents/skills", "codex"),
43
+ ("github-copilot", "GitHub Copilot", ".copilot/skills", "copilot"),
44
+ ("claude-code", "Claude Code", ".claude/skills", "claude"),
45
+ ):
46
+ app_folder = home / {"codex": ".codex", "github-copilot": ".copilot", "claude-code": ".claude"}[key]
47
+ root = home / folder
48
+ if key == "github-copilot" and name and not (root / name).exists() and (home / ".agents/skills" / name / "SKILL.md").is_file():
49
+ root = home / ".agents/skills"
50
+ label += " (shared directory)"
51
+ options.append(Destination(key, label, root, key, detected=app_folder.is_dir() or shutil.which(command) is not None or (Path("/Applications") / {"codex": "Codex.app", "github-copilot": "GitHub Copilot.app", "claude-code": "Claude Code.app"}[key]).is_dir()))
52
+ options.append(Destination("copilot-cowork", "Microsoft 365 Copilot Cowork", home / ".skillctl" / "exports" / "cowork" / "skills", "copilot-cowork", "import", scope="Account import"))
53
+ saved = load_json_state(registry_path())
54
+ for item in saved.get("destinations", []):
55
+ options.append(Destination(**{**item, "root": Path(item["root"])}))
56
+ return options
57
+
58
+
59
+ @exclusive_operation
60
+ def add_destination(label, root, host, scope="Project"):
61
+ root = Path(root).expanduser()
62
+ if not label.strip() or not root.is_absolute():
63
+ raise ValueError("Enter a name and an absolute skill directory path.")
64
+ if host not in {"codex", "github-copilot", "claude-code"}:
65
+ raise ValueError("Choose a supported local AI app.")
66
+ root = root.absolute()
67
+ if root == Path(root.anchor) or root == Path.home() or root.is_file():
68
+ raise ValueError("Choose a dedicated skill directory, not a home directory, drive root, or file.")
69
+ key = "custom-" + hashlib.sha256(str(root).encode()).hexdigest()[:16]
70
+ state = load_json_state(registry_path())
71
+ entries = state.setdefault("destinations", [])
72
+ entries[:] = [item for item in entries if item["id"] != key]
73
+ entries.append(dict(id=key, label=label.strip(), root=str(root), host=host, mode="local", detected=False, scope=scope))
74
+ save_json_state(registry_path(), state)
75
+ return key
76
+
77
+
78
+ def destination_manager(manager, destination):
79
+ """Independent state and transactions; never switch the user's active toolbox."""
80
+ clone = copy(manager)
81
+ clone._skill_transaction_state = threading.local()
82
+ clone.config = deepcopy(manager.config)
83
+ # A destination must not rewrite membership of the active named toolbox.
84
+ clone.sync_environment_membership = lambda: None
85
+ clone.install_dir = destination.root.expanduser().absolute()
86
+ # Reuse ownership records for existing toolboxes, including the legacy default.
87
+ roots = [(manager.install_dir, manager.state_file, manager.lockfile.path, manager.backup_dir),
88
+ (manager._base_install_dir, manager._base_state_file, manager._base_lockfile, manager.backup_dir)]
89
+ for item in manager.environment_store.load().get("environments", {}).values():
90
+ roots.append((Path(item["install_dir"]), Path(item["state_file"]), Path(item["lockfile"]), Path(item["install_dir"]).parent / "backups"))
91
+ for root, state, lock, backup in roots:
92
+ if Path(root).resolve() == clone.install_dir.resolve():
93
+ clone.state_file, clone.lockfile, clone.backup_dir = Path(state), Lockfile(Path(lock)), Path(backup)
94
+ if Path(root).resolve() == manager.install_dir.resolve():
95
+ clone.sync_environment_membership = type(manager).sync_environment_membership.__get__(clone)
96
+ return clone
97
+ identity = hashlib.sha256(str(clone.install_dir.resolve()).encode()).hexdigest()
98
+ base = Path.home() / ".skillctl" / "destinations" / identity
99
+ clone.state_file, clone.lockfile, clone.backup_dir = base / "state.json", Lockfile(base / "skills.lock"), base / "backups"
100
+ return clone
101
+
102
+
103
+ def destination_status(manager, destination, name):
104
+ dm = destination_manager(manager, destination)
105
+ path = dm.install_dir / name
106
+ record = dm._load_state().get(name, {})
107
+ if not path.exists():
108
+ status = "Needs attention: files missing" if record else "Not installed"
109
+ elif path.is_symlink():
110
+ status = "Needs attention: linked skill (not managed here)"
111
+ else:
112
+ valid, _ = dm.validate_skill(path, name)
113
+ if not valid:
114
+ status = "Needs attention: invalid skill files"
115
+ elif not record.get("sha256"):
116
+ status = "Installed locally (unmanaged)"
117
+ elif dm._hash_tree(path) != record["sha256"]:
118
+ status = "Needs attention: files changed"
119
+ else:
120
+ status = "Installed locally"
121
+ if destination.mode == "import" and status.startswith("Installed locally"):
122
+ archive = path.parent / (path.name + ".zip")
123
+ exported = record.get("cowork_export", {})
124
+ current = (archive.is_file() and exported.get("source_sha256") == record.get("sha256")
125
+ and exported.get("archive_sha256") == hashlib.sha256(archive.read_bytes()).hexdigest())
126
+ status = "Import required" if current else "Needs attention: import package missing or stale"
127
+ return dict(destination=destination.label, status=status, path=str(path), version=record.get("commit", ""), discovery="App discovery unverified")
128
+
129
+
130
+ def status_text(manager, name):
131
+ lines = ["AI app destinations (file placement; app discovery unverified)"]
132
+ for target in known_destinations(manager, name):
133
+ row = destination_status(manager, target, name)
134
+ lines.append(f"{row['destination']}: {row['status']}\n {row['path']}" + (f"\n Revision: {row['version'][:12]}" if row['version'] else ""))
135
+ shared = Path.home() / ".agents" / "skills" / name / "SKILL.md"
136
+ if shared.is_file():
137
+ lines.append("Shared .agents/skills copy: also a GitHub Copilot discovery location. Removing it can affect every app using this directory.")
138
+ return "\n".join(lines)
139
+
140
+
141
+ @dataclass
142
+ class DestinationPlan:
143
+ name: str
144
+ operation: str
145
+ scope: tuple
146
+ groups: list = field(default_factory=list)
147
+ reviews: dict = field(default_factory=dict)
148
+
149
+
150
+ @exclusive_operation
151
+ def review_destinations(manager, name, destinations, operation="install", progress=None):
152
+ manager.reload()
153
+ plan = DestinationPlan(name, operation, _scope(manager))
154
+ dependencies = manager.dependency_plan(name)
155
+ if dependencies.get("missing"):
156
+ raise RuntimeError("Missing dependencies: " + ", ".join(dependencies["missing"]))
157
+ seen = set()
158
+ for destination in destinations:
159
+ root = destination.root.expanduser().resolve()
160
+ # Shared discovery roots are one physical operation.
161
+ if root in seen:
162
+ for i, (existing, dm, resolved, batch) in enumerate(plan.groups):
163
+ if resolved == root:
164
+ plan.groups[i] = (replace(existing, label=existing.label + " / " + destination.label), dm, resolved, batch)
165
+ break
166
+ continue
167
+ seen.add(root)
168
+ dm = destination_manager(manager, destination)
169
+ batch = UpdateBatch(_scope(dm), [])
170
+ plan.groups.append((destination, dm, root, batch))
171
+ state = dm._load_state()
172
+ for skill in ([name] if operation == "remove" else dependencies["order"]):
173
+ spec = dm.get(skill)
174
+ path = dm.install_dir / skill
175
+ item = UpdateItem(skill, "FAILED", spec=deepcopy(spec), baseline=deepcopy(state.get(skill)), initial_install=not path.exists())
176
+ batch.items.append(item)
177
+ try:
178
+ if path.is_symlink():
179
+ raise RuntimeError("Linked skill is unmanaged here; choose its owning destination.")
180
+ if path.exists():
181
+ if not state.get(skill, {}).get("sha256"):
182
+ raise RuntimeError("Existing files are unmanaged. They will not be overwritten or removed.")
183
+ item.digest = dm._hash_tree(path)
184
+ if item.digest != state[skill]["sha256"]:
185
+ raise RuntimeError("Installed files changed. Resolve local edits before continuing.")
186
+ if operation == "remove":
187
+ if not path.exists():
188
+ item.status, item.message = "CURRENT", "Not installed at this destination."
189
+ elif dm.dependents(skill, installed_only=True):
190
+ raise RuntimeError("Other installed skills depend on this skill.")
191
+ else:
192
+ item.status, item.message = "READY", "Remove this managed copy; other destinations remain unchanged."
193
+ continue
194
+ if path.exists() and (operation == "install" or skill != name):
195
+ item.status, item.message = "CURRENT", "Already installed locally."
196
+ continue
197
+ if operation == "update" and skill == name and not path.exists():
198
+ raise RuntimeError("Not installed here. Use Install to selected apps first.")
199
+ if not spec.enabled:
200
+ raise RuntimeError("Skill is disabled in the catalog.")
201
+ if operation == "update" and re.fullmatch(r"[0-9a-fA-F]{7,40}", spec.ref or ""):
202
+ raise RuntimeError("Skill is pinned. Unpin it before updating.")
203
+ if skill not in plan.reviews:
204
+ if progress: progress(f"Reviewing source: {skill}…")
205
+ plan.reviews[skill] = dm.update_review(spec)
206
+ item.review = deepcopy(plan.reviews[skill])
207
+ target = item.review["target_commit"]
208
+ if not spec.local_path and not re.fullmatch(r"[0-9a-fA-F]{40}", target):
209
+ raise RuntimeError("Source did not resolve to an exact commit.")
210
+ decision = item.review["policy_after"]["decision"]
211
+ # Destinations never bypass a DENY, even if another destination succeeds.
212
+ item.status = {"ALLOW": "READY", "REVIEW": "REVIEW"}.get(decision, "BLOCKED")
213
+ if item.status == "READY" and not spec.trusted and item.review.get("audit_after", {}).get("high", 0):
214
+ item.status = "REVIEW"
215
+ item.message = "Policy: " + decision
216
+ except Exception as exc:
217
+ item.status, item.message = "FAILED", str(exc)
218
+ return plan
219
+
220
+
221
+ def _export_cowork(dm, name):
222
+ source = dm.install_dir / name
223
+ output = source.parent / (source.name + ".zip")
224
+ temporary = output.with_suffix(".zip.tmp")
225
+ files = [p for p in source.rglob("*") if p.is_file()]
226
+ if any(p.is_symlink() for p in source.rglob("*")):
227
+ raise RuntimeError("Import package cannot contain symlinks.")
228
+ if len(files) > 100 or sum(p.stat().st_size for p in files) > 50 * 1024 * 1024:
229
+ raise RuntimeError("Cowork package exceeds 100 files or 50 MB uncompressed.")
230
+ try:
231
+ with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
232
+ for path in files:
233
+ archive.write(path, path.relative_to(source))
234
+ if temporary.stat().st_size > 10 * 1024 * 1024:
235
+ raise RuntimeError("Cowork package exceeds 10 MB compressed.")
236
+ os.replace(temporary, output)
237
+ state = dm._load_state()
238
+ state[name]["cowork_export"] = dict(source_sha256=dm._hash_tree(source), archive_sha256=hashlib.sha256(output.read_bytes()).hexdigest())
239
+ dm._save_state(state)
240
+ finally:
241
+ temporary.unlink(missing_ok=True)
242
+ return output
243
+
244
+
245
+ @exclusive_operation
246
+ def apply_destinations(manager, plan, approved=False, progress=None):
247
+ if _scope(manager) != plan.scope:
248
+ raise RuntimeError("Active toolbox changed; review destinations again.")
249
+ results = []
250
+ for destination, dm, root, batch in plan.groups:
251
+ try:
252
+ if destination.root.expanduser().resolve() != root:
253
+ raise RuntimeError("Destination link changed; review again.")
254
+ dm.reload()
255
+ for item in batch.items:
256
+ if item.status not in {"READY", "REVIEW"}:
257
+ continue
258
+ if plan.operation == "remove":
259
+ path = dm.install_dir / item.name
260
+ if path.is_symlink() or dm._hash_tree(path) != item.digest or dm._load_state().get(item.name) != item.baseline or dm.get(item.name) != item.spec:
261
+ raise RuntimeError("Files, ownership, or catalog changed; review again.")
262
+ elif item.review.get("risk_receipt", {}).get("policy") != dm.policy_engine.load():
263
+ raise RuntimeError("Policy changed; review again.")
264
+ elif item.spec.local_path:
265
+ from .risk_acknowledgment import validate_receipt
266
+ validate_receipt(dm, item.spec, item.review["target_commit"], item.spec.local_path, item.review.get("risk_receipt"))
267
+ if plan.operation == "remove":
268
+ rows = []
269
+ for item in batch.items:
270
+ if item.status == "READY":
271
+ dm.remove(dm.get(item.name))
272
+ if destination.mode == "import":
273
+ (dm.install_dir / (item.name + ".zip")).unlink(missing_ok=True)
274
+ rows.append(dict(skill=item.name, status="REMOVED", message="Local copy removed."))
275
+ else:
276
+ rows.append(dict(skill=item.name, status=item.status, message=item.message))
277
+ else:
278
+ dm.install_dir.mkdir(parents=True, exist_ok=True)
279
+ dm.backup_dir.mkdir(parents=True, exist_ok=True)
280
+ rows = apply_updates(dm, batch, approve_review=approved, progress=progress)
281
+ for row in rows:
282
+ if destination.mode == "import" and row["status"] in {"APPLIED", "CURRENT"}:
283
+ try:
284
+ output = _export_cowork(dm, row["skill"])
285
+ row.update(status="IMPORT REQUIRED", message=f"{output}\nIn Cowork: Customize → Skills → Add → Upload skill. Import each dependency package too. Test in a new conversation. Runtime prerequisites still require review.")
286
+ except Exception as exc:
287
+ row.update(status="FAILED", message=f"Import package failed: {exc}")
288
+ for row in rows:
289
+ row["destination"] = destination.label
290
+ if destination.mode == "import" and plan.operation == "remove":
291
+ row["message"] += " Remove the imported skill separately in Cowork."
292
+ results.append(row)
293
+ except Exception as exc:
294
+ results.append(dict(destination=destination.label, skill=plan.name, status="FAILED", message=str(exc)))
295
+ return results
296
+
297
+
298
+ def format_destination_plan(plan):
299
+ from .findings_tui import GUIDANCE
300
+ lines = ["Review selected destinations", "Destinations apply independently. A failed destination does not undo successful changes.",
301
+ "DENY items remain blocked; REVIEW items require explicit risk approval."]
302
+ for target, _, _, batch in plan.groups:
303
+ lines.extend(["", f"{target.label} → {target.root}"])
304
+ if target.mode == "import":
305
+ lines.append("Local import package only. Upload it in Cowork; review runtime prerequisites. Copilot Chat is a separate surface.")
306
+ for item in batch.items:
307
+ lines.append(f"{item.name}: {item.status} — {item.message}")
308
+ if item.baseline:
309
+ lines.append(f"Existing revision: {item.baseline.get('commit', 'unknown')}")
310
+ if target.host and item.review:
311
+ evidence = next((r for r in item.review.get("compatibility", []) if r["host"] == target.host), None)
312
+ if evidence:
313
+ lines.append(f"Compatibility: {evidence['format']} / runtime {evidence['runtime']} (static assessment)")
314
+ lines.extend(evidence.get("reasons", []))
315
+ for name, review in plan.reviews.items():
316
+ lines.extend(["", f"Source review: {name}", f"Exact revision: {review['target_commit']}",
317
+ f"Audit: {review['audit_after']}", f"Policy: {review['policy_after']['decision']}"])
318
+ lines.extend(review['policy_after'].get('reasons', []))
319
+ for finding in review.get("findings", []):
320
+ risk, fix = GUIDANCE.get(finding['message'], ('Inspect the source to determine the risk.', 'Review and correct unsafe behavior before use.'))
321
+ lines.extend([f"{finding['severity']}: {finding['message']} — {finding['path']}", f"Risk: {risk}", f"Suggested fix: {fix}"])
322
+ lines.extend(review.get("intent_after", {}).get("statements", []))
323
+ lines.extend(["Source diff against first destination requiring this skill:", review.get("diff", "")])
324
+ return "\n".join(lines)
@@ -0,0 +1,67 @@
1
+ """Explicit multi-destination operations for CLI and automation users."""
2
+ from typing import List, Optional
3
+ import typer
4
+ from rich.table import Table
5
+ from .cli import app, console, manager
6
+ from .destinations import (known_destinations, destination_status, review_destinations,
7
+ apply_destinations, format_destination_plan)
8
+
9
+
10
+ @app.command("destinations")
11
+ def destinations_cmd(ctx: typer.Context, skill: Optional[str] = typer.Argument(None)):
12
+ """List AI app destinations, locations, and optional per-skill status."""
13
+ m = manager(ctx.obj['manifest'])
14
+ if skill:
15
+ m.get(skill)
16
+ table = Table("Target ID", "AI app / toolbox", "Scope", "Location", "Status")
17
+ for target in known_destinations(m, skill):
18
+ status = destination_status(m, target, skill)['status'] if skill else ('Import required' if target.mode == 'import' else 'Detected/configured' if target.detected else 'Not detected')
19
+ from rich.text import Text
20
+ table.add_row(*(Text(str(v)) for v in (target.id, target.label, target.scope, target.root, status)))
21
+ console.print(table)
22
+ console.print('File placement does not confirm app discovery. The active toolbox is independent of AI app destinations.')
23
+
24
+
25
+ @app.command("install-to")
26
+ def install_to_cmd(ctx: typer.Context, skill: str, target: List[str] = typer.Option(..., "--target", "-t"),
27
+ operation: str = typer.Option("install", "--operation", help="install, update, or remove"),
28
+ apply: bool = typer.Option(False, "--apply", help="Apply the displayed destination review."),
29
+ approve_review: bool = typer.Option(False, "--approve-review", help="Explicitly approve REVIEW risks; DENY remains blocked.")):
30
+ """Review installation to one or many destinations; --apply performs it."""
31
+ if operation not in {'install', 'update', 'remove'}:
32
+ raise typer.BadParameter('Choose install, update, or remove.', param_hint='--operation')
33
+ m = manager(ctx.obj['manifest'])
34
+ options = {d.id: d for d in known_destinations(m, skill)}
35
+ missing = [key for key in target if key not in options]
36
+ if missing:
37
+ raise typer.BadParameter('Unknown destinations: ' + ', '.join(missing), param_hint='--target')
38
+ plan = review_destinations(m, skill, [options[key] for key in target], operation)
39
+ console.print(format_destination_plan(plan), markup=False)
40
+ if not apply:
41
+ console.print('Review only. Pass --apply to perform the operation.')
42
+ return
43
+ rows = apply_destinations(m, plan, approved=approve_review)
44
+ for row in rows:
45
+ console.print(f"{row['destination']} · {row['skill']}: {row['status']} — {row['message']}", markup=False)
46
+ if any(row['status'] in {'FAILED', 'BLOCKED', 'SKIPPED'} for row in rows):
47
+ raise typer.Exit(1)
48
+
49
+
50
+ @app.command("inventory")
51
+ def inventory_cmd(ctx: typer.Context, json_output: bool = typer.Option(False, "--json")):
52
+ """Discover existing local skills, including skills outside the source catalog."""
53
+ from .inventory import scan_inventory, inventory_summary
54
+ from rich.text import Text
55
+ import json
56
+ result = scan_inventory(manager(ctx.obj['manifest']))
57
+ if json_output:
58
+ typer.echo(json.dumps(result, indent=2))
59
+ return
60
+ console.print(inventory_summary(result), markup=False)
61
+ table = Table("Skill", "Destinations", "Ownership", "Status", "Location")
62
+ for row in result['rows']:
63
+ table.add_row(*(Text(value) for value in (row['name'], ', '.join(row['destinations']), row['ownership'], row['state'], row['paths'][0])))
64
+ console.print(table)
65
+ for error in result['errors']:
66
+ console.print(error, markup=False)
67
+ console.print('Known local directories only. App discovery and content integrity remain unverified.')