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.
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/PKG-INFO +69 -1
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/README.md +68 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/pyproject.toml +1 -1
- skillctl_secure-0.29.0/skillctl/__init__.py +1 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/compatibility.py +15 -2
- skillctl_secure-0.29.0/skillctl/destinations.py +324 -0
- skillctl_secure-0.29.0/skillctl/destinations_cli.py +67 -0
- skillctl_secure-0.29.0/skillctl/destinations_tui.py +214 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/entrypoint.py +1 -0
- skillctl_secure-0.29.0/skillctl/findings_tui.py +108 -0
- skillctl_secure-0.29.0/skillctl/inventory.py +195 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/manager.py +2 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/recommendation_tui.py +2 -15
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/section_navigation.py +1 -1
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_interaction.py +13 -2
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_original.py +46 -69
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_progressive.py +2 -1
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui_refresh.py +23 -3
- skillctl_secure-0.28.2/skillctl/__init__.py +0 -1
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/.gitignore +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/LICENSE +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/__main__.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/authcontrols.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/benchmark.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/browsercontrols.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_cli.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_plan.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_update.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/bulk_update_tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cacheutil.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/candidates.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_discovery.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_index.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_schema.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_search_cli.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/catalog_search_tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cicdflow.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cli.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/cli_contract.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/context_cost.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/context_tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/copilot_export.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/coverage.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/dedupe.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/doctor.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/environments.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/estimate.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/evidence.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/findings.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/flow.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/governor.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/hardening.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/history_tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/iac.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/install_intent.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/install_review.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/intent_diff.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/journal.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/journal_hooks.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/journal_operations.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/lockfile.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/models.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/navigation_tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/operation_lock.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/packet.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/paths.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/permissions.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/policy.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/policy_editor.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/precision.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/profiles.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/publishers.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/ranking.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/recommendation.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_cli.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_recovery.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_transaction.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconcile_tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/reconciliation.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/remote.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/answer-key.yaml +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/.env +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/.github/workflows/build.yml +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/README.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/app.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/openapi.yaml +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/requirements.txt +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/scripts/build.sh +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/templates/payment.html +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/benchmarks/vulnlab/target/trivy-results.json +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/.gitkeep +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/api-security-review/SKILL.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/api-security-review/references/inventory-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/api-security-review/references/output-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/SKILL.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/disposition.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/orchestration.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/output-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/review-checklist.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/biso-security-review/references/risk-rating.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/cicd-security-review/SKILL.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/cicd-security-review/references/output-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/pci-architect/SKILL.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/pci-architect/references/control-matrix.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/pci-architect/references/output-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/threat-model/SKILL.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/threat-model/references/output-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/threat-model/references/risk-rating.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/SKILL.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/references/escalation.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/references/output-template.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/custom/vulnerability-manager/references/prioritization.md +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/finding-schema.json +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/resources/skills.yaml +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/review.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/review_candidates.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/risk_acknowledgment.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/runtime_binding.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/safety_plan.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/stagecache.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/stateio.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/transactions.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/trust.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/tui.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/update_intent_review.py +0 -0
- {skillctl_secure-0.28.2 → skillctl_secure-0.29.0}/skillctl/update_intent_tui.py +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
-
"
|
|
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.')
|