skillctl-secure 0.28.0__py3-none-any.whl

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 (122) hide show
  1. skillctl/__init__.py +1 -0
  2. skillctl/__main__.py +5 -0
  3. skillctl/authcontrols.py +165 -0
  4. skillctl/benchmark.py +150 -0
  5. skillctl/browsercontrols.py +200 -0
  6. skillctl/bulk_cli.py +66 -0
  7. skillctl/bulk_plan.py +96 -0
  8. skillctl/bulk_update.py +173 -0
  9. skillctl/bulk_update_tui.py +196 -0
  10. skillctl/cacheutil.py +21 -0
  11. skillctl/candidates.py +244 -0
  12. skillctl/catalog.py +48 -0
  13. skillctl/catalog_discovery.py +67 -0
  14. skillctl/catalog_index.py +195 -0
  15. skillctl/catalog_schema.py +58 -0
  16. skillctl/catalog_search_cli.py +100 -0
  17. skillctl/catalog_search_tui.py +188 -0
  18. skillctl/cicdflow.py +89 -0
  19. skillctl/cli.py +958 -0
  20. skillctl/cli_contract.py +71 -0
  21. skillctl/compatibility.py +283 -0
  22. skillctl/context_cost.py +90 -0
  23. skillctl/context_tui.py +84 -0
  24. skillctl/copilot_export.py +188 -0
  25. skillctl/coverage.py +11 -0
  26. skillctl/dedupe.py +14 -0
  27. skillctl/doctor.py +113 -0
  28. skillctl/entrypoint.py +207 -0
  29. skillctl/environments.py +193 -0
  30. skillctl/estimate.py +136 -0
  31. skillctl/evidence.py +133 -0
  32. skillctl/findings.py +37 -0
  33. skillctl/flow.py +126 -0
  34. skillctl/governor.py +50 -0
  35. skillctl/hardening.py +195 -0
  36. skillctl/history_tui.py +149 -0
  37. skillctl/iac.py +95 -0
  38. skillctl/install_intent.py +148 -0
  39. skillctl/install_review.py +58 -0
  40. skillctl/intent_diff.py +97 -0
  41. skillctl/journal.py +157 -0
  42. skillctl/journal_hooks.py +37 -0
  43. skillctl/journal_operations.py +148 -0
  44. skillctl/lockfile.py +59 -0
  45. skillctl/manager.py +1533 -0
  46. skillctl/models.py +56 -0
  47. skillctl/navigation_tui.py +92 -0
  48. skillctl/operation_lock.py +111 -0
  49. skillctl/packet.py +46 -0
  50. skillctl/paths.py +35 -0
  51. skillctl/permissions.py +20 -0
  52. skillctl/policy.py +96 -0
  53. skillctl/policy_editor.py +146 -0
  54. skillctl/precision.py +197 -0
  55. skillctl/profiles.py +89 -0
  56. skillctl/publishers.py +25 -0
  57. skillctl/ranking.py +87 -0
  58. skillctl/recommendation.py +153 -0
  59. skillctl/recommendation_tui.py +351 -0
  60. skillctl/reconcile_cli.py +181 -0
  61. skillctl/reconcile_recovery.py +267 -0
  62. skillctl/reconcile_transaction.py +274 -0
  63. skillctl/reconcile_tui.py +335 -0
  64. skillctl/reconciliation.py +253 -0
  65. skillctl/remote.py +163 -0
  66. skillctl/resources/benchmarks/vulnlab/answer-key.yaml +122 -0
  67. skillctl/resources/benchmarks/vulnlab/target/.env +2 -0
  68. skillctl/resources/benchmarks/vulnlab/target/.github/workflows/build.yml +19 -0
  69. skillctl/resources/benchmarks/vulnlab/target/README.md +6 -0
  70. skillctl/resources/benchmarks/vulnlab/target/app.py +35 -0
  71. skillctl/resources/benchmarks/vulnlab/target/openapi.yaml +6 -0
  72. skillctl/resources/benchmarks/vulnlab/target/requirements.txt +2 -0
  73. skillctl/resources/benchmarks/vulnlab/target/scripts/build.sh +4 -0
  74. skillctl/resources/benchmarks/vulnlab/target/templates/payment.html +4 -0
  75. skillctl/resources/benchmarks/vulnlab/target/trivy-results.json +16 -0
  76. skillctl/resources/custom/.gitkeep +0 -0
  77. skillctl/resources/custom/api-security-review/SKILL.md +149 -0
  78. skillctl/resources/custom/api-security-review/references/inventory-template.md +8 -0
  79. skillctl/resources/custom/api-security-review/references/output-template.md +33 -0
  80. skillctl/resources/custom/biso-security-review/SKILL.md +135 -0
  81. skillctl/resources/custom/biso-security-review/references/disposition.md +15 -0
  82. skillctl/resources/custom/biso-security-review/references/orchestration.md +46 -0
  83. skillctl/resources/custom/biso-security-review/references/output-template.md +55 -0
  84. skillctl/resources/custom/biso-security-review/references/review-checklist.md +36 -0
  85. skillctl/resources/custom/biso-security-review/references/risk-rating.md +16 -0
  86. skillctl/resources/custom/cicd-security-review/SKILL.md +133 -0
  87. skillctl/resources/custom/cicd-security-review/references/output-template.md +27 -0
  88. skillctl/resources/custom/pci-architect/SKILL.md +109 -0
  89. skillctl/resources/custom/pci-architect/references/control-matrix.md +12 -0
  90. skillctl/resources/custom/pci-architect/references/output-template.md +44 -0
  91. skillctl/resources/custom/threat-model/SKILL.md +69 -0
  92. skillctl/resources/custom/threat-model/references/output-template.md +44 -0
  93. skillctl/resources/custom/threat-model/references/risk-rating.md +25 -0
  94. skillctl/resources/custom/vulnerability-manager/SKILL.md +132 -0
  95. skillctl/resources/custom/vulnerability-manager/references/escalation.md +18 -0
  96. skillctl/resources/custom/vulnerability-manager/references/output-template.md +21 -0
  97. skillctl/resources/custom/vulnerability-manager/references/prioritization.md +36 -0
  98. skillctl/resources/finding-schema.json +131 -0
  99. skillctl/resources/skills.yaml +1907 -0
  100. skillctl/review.py +495 -0
  101. skillctl/review_candidates.py +43 -0
  102. skillctl/risk_acknowledgment.py +33 -0
  103. skillctl/runtime_binding.py +141 -0
  104. skillctl/safety_plan.py +156 -0
  105. skillctl/section_navigation.py +37 -0
  106. skillctl/stagecache.py +92 -0
  107. skillctl/stateio.py +55 -0
  108. skillctl/transactions.py +131 -0
  109. skillctl/trust.py +72 -0
  110. skillctl/tui.py +304 -0
  111. skillctl/tui_interaction.py +125 -0
  112. skillctl/tui_original.py +1646 -0
  113. skillctl/tui_progressive.py +102 -0
  114. skillctl/tui_refresh.py +286 -0
  115. skillctl/update_intent_review.py +74 -0
  116. skillctl/update_intent_tui.py +57 -0
  117. skillctl/vulnnormalize.py +114 -0
  118. skillctl_secure-0.28.0.dist-info/METADATA +292 -0
  119. skillctl_secure-0.28.0.dist-info/RECORD +122 -0
  120. skillctl_secure-0.28.0.dist-info/WHEEL +4 -0
  121. skillctl_secure-0.28.0.dist-info/entry_points.txt +2 -0
  122. skillctl_secure-0.28.0.dist-info/licenses/LICENSE +202 -0
skillctl/bulk_plan.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable
4
+
5
+ from .install_intent import aggregate_plan_intent
6
+ from .safety_plan import build_install_plan
7
+
8
+
9
+ _GATE_RANK = {"ALLOW": 0, "REVIEW": 1, "BLOCK": 2}
10
+
11
+
12
+ def _max_gate(current: str, candidate: str) -> str:
13
+ return candidate if _GATE_RANK.get(candidate, 2) > _GATE_RANK.get(current, 2) else current
14
+
15
+
16
+ def build_bulk_install_plan(manager, skill_names: Iterable[str]) -> dict[str, Any]:
17
+ """Build one read-only, fail-closed plan for multiple requested skills.
18
+
19
+ Shared dependencies are deduplicated while preserving first-seen dependency
20
+ order. Per-skill evidence is retained unchanged from build_install_plan().
21
+ """
22
+ requested: list[str] = []
23
+ seen_requested: set[str] = set()
24
+ for raw in skill_names:
25
+ name = str(raw).strip()
26
+ if name and name not in seen_requested:
27
+ seen_requested.add(name)
28
+ requested.append(name)
29
+
30
+ known = {spec.name for spec in manager.specs()}
31
+ unknown = [name for name in requested if name not in known]
32
+ root_plans: list[dict[str, Any]] = []
33
+ gate = "BLOCK" if unknown else "ALLOW"
34
+ gate_reasons: list[str] = [f"Unknown requested skill: {name}" for name in unknown]
35
+
36
+ ordered_steps: list[dict[str, Any]] = []
37
+ step_index: dict[str, dict[str, Any]] = {}
38
+ required_by: dict[str, list[str]] = {}
39
+
40
+ for root in requested:
41
+ if root not in known:
42
+ continue
43
+ plan = build_install_plan(manager, root)
44
+ root_plans.append(plan)
45
+ gate = _max_gate(gate, str(plan.get("gate", "BLOCK")))
46
+ gate_reasons.extend(f"{root}: {reason}" for reason in plan.get("gate_reasons", []))
47
+
48
+ for step in plan.get("steps", []):
49
+ skill = str(step.get("skill", ""))
50
+ required_by.setdefault(skill, [])
51
+ if root not in required_by[skill]:
52
+ required_by[skill].append(root)
53
+ if skill in step_index:
54
+ continue
55
+ copied = dict(step)
56
+ copied["required_by"] = required_by[skill]
57
+ step_index[skill] = copied
58
+ ordered_steps.append(copied)
59
+
60
+ # required_by lists may have grown after a shared dependency's first insertion.
61
+ for step in ordered_steps:
62
+ step["required_by"] = list(required_by.get(step["skill"], []))
63
+
64
+ mutations = [
65
+ {"action": step["action"], "skill": step["skill"], "destination": step["destination"]}
66
+ for step in ordered_steps
67
+ if step.get("action") == "INSTALL"
68
+ ]
69
+
70
+ return {
71
+ "schema_version": 1,
72
+ "operation": "BULK_INSTALL",
73
+ "environment": manager.active_environment,
74
+ "install_root": str(manager.install_dir),
75
+ "requested": requested,
76
+ "unknown_requested": unknown,
77
+ "gate": gate,
78
+ "gate_reasons": gate_reasons,
79
+ "mutation_count": len(mutations),
80
+ "mutations": mutations,
81
+ "intent": aggregate_plan_intent(ordered_steps),
82
+ "steps": ordered_steps,
83
+ "root_plans": [
84
+ {
85
+ "skill": plan["skill"],
86
+ "gate": plan["gate"],
87
+ "dependency_order": plan["dependency_order"],
88
+ "missing_dependencies": plan["missing_dependencies"],
89
+ }
90
+ for plan in root_plans
91
+ ],
92
+ "no_mutation_performed": True,
93
+ }
94
+
95
+
96
+ __all__ = ["build_bulk_install_plan"]
@@ -0,0 +1,173 @@
1
+ """Review and apply installed updates, preserving each skill's policy gate."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass, field
6
+ import re
7
+
8
+ from .operation_lock import exclusive_operation
9
+
10
+
11
+ @dataclass
12
+ class UpdateItem:
13
+ name: str
14
+ status: str
15
+ message: str = ""
16
+ spec: object = None
17
+ baseline: dict = field(default_factory=dict)
18
+ digest: str = ""
19
+ review: dict = field(default_factory=dict)
20
+ initial_install: bool = False
21
+
22
+
23
+ @dataclass
24
+ class UpdateBatch:
25
+ scope: tuple[str, ...]
26
+ items: list[UpdateItem]
27
+
28
+
29
+ def _scope(manager):
30
+ store = getattr(manager, "environment_store", None)
31
+ active = store.load().get("active", "default") if store else "default"
32
+ return (str(manager.install_dir.resolve()), str(manager.state_file.resolve()), active)
33
+
34
+
35
+ @exclusive_operation
36
+ def review_updates(manager, names=None, progress=None) -> UpdateBatch:
37
+ manager.reload()
38
+ state = manager._load_state()
39
+ batch = UpdateBatch(_scope(manager), [])
40
+ for spec in manager.specs():
41
+ if names is not None and spec.name not in names:
42
+ continue
43
+ if not (manager.install_dir / spec.name).is_dir():
44
+ continue
45
+ if spec.name not in state:
46
+ batch.items.append(UpdateItem(spec.name, "FAILED", "Installed files have no state record. Open Doctor and recover the state record before updating.", spec=deepcopy(spec)))
47
+ continue
48
+ if progress:
49
+ progress(f"Checking {spec.name}…")
50
+ item = UpdateItem(spec.name, "SKIPPED", spec=deepcopy(spec))
51
+ batch.items.append(item)
52
+ if not spec.enabled or spec.local_path or re.fullmatch(r"[0-9a-fA-F]{7,40}", spec.ref or ""):
53
+ item.message = ("Disabled in the catalog." if not spec.enabled else
54
+ "Local skill: managed from its local source, not a Git update." if spec.local_path else
55
+ "Pinned to a commit. Unpin the skill before checking for updates.")
56
+ continue
57
+ try:
58
+ item.baseline = deepcopy(state[spec.name])
59
+ item.digest = manager._hash_tree(manager.install_dir / spec.name)
60
+ item.review = manager.update_review(spec)
61
+ target = item.review["target_commit"]
62
+ if target == item.baseline.get("commit"):
63
+ item.status, item.message = "CURRENT", "Already up to date."
64
+ continue
65
+ if not re.fullmatch(r"[0-9a-fA-F]{40}", target):
66
+ raise ValueError("Update did not resolve to an exact Git commit.")
67
+ decision = item.review["policy_after"]["decision"]
68
+ item.status = {"ALLOW": "READY", "REVIEW": "REVIEW"}.get(decision, "BLOCKED")
69
+ # The install gate also requires approval for untrusted HIGH findings.
70
+ if item.status == "READY" and not spec.trusted and item.review.get("audit_after", {}).get("high", 0):
71
+ item.status = "REVIEW"
72
+ item.message = "Policy: " + decision
73
+ except Exception as exc:
74
+ item.status, item.message = "FAILED", str(exc)
75
+ return batch
76
+
77
+
78
+ @exclusive_operation
79
+ def apply_updates(manager, batch: UpdateBatch, *, approve_review: bool = False, acknowledge_risk: bool = False, progress=None) -> list[dict]:
80
+ manager.reload()
81
+ if _scope(manager) != batch.scope:
82
+ raise RuntimeError("Environment changed; review updates again.")
83
+ results = []
84
+ for item in batch.items:
85
+ result = {"skill": item.name, "status": item.status, "message": item.message}
86
+ results.append(result)
87
+ denied = item.status == "BLOCKED" and item.review.get("policy_after", {}).get("decision") == "DENY"
88
+ if item.status not in {"READY", "REVIEW"} and not (denied and acknowledge_risk):
89
+ continue
90
+ if item.status == "REVIEW" and not approve_review:
91
+ result.update(status="SKIPPED", message="Explicit review approval was not granted.")
92
+ continue
93
+ try:
94
+ if progress:
95
+ progress(f"Installing {item.name}…")
96
+ spec = manager.get(item.name)
97
+ path = manager.install_dir / item.name
98
+ changed = (path.exists() if item.initial_install else
99
+ not path.is_dir() or manager._hash_tree(path) != item.digest)
100
+ if spec != item.spec or manager._load_state().get(item.name) != item.baseline or changed:
101
+ raise RuntimeError("Installed skill or catalog changed; review again.")
102
+ target = item.review["target_commit"]
103
+ if item.initial_install:
104
+ missing = [name for name in spec.requires if not (manager.install_dir / name).is_dir()]
105
+ if missing:
106
+ raise RuntimeError('Required skills were not installed: ' + ', '.join(missing))
107
+ if not spec.enabled:
108
+ raise RuntimeError('Skill is disabled in the catalog.')
109
+ if item.initial_install and spec.local_path:
110
+ from .risk_acknowledgment import validate_receipt
111
+ receipt = item.review.get('risk_receipt')
112
+ if not receipt:
113
+ raise RuntimeError('Source review is incomplete; review again.')
114
+ validate_receipt(manager, spec, target, spec.local_path, receipt)
115
+ kwargs = {'risk_acknowledgment': receipt} if denied else {}
116
+ installed = manager.install(spec, force=denied or (item.status == 'REVIEW' and approve_review), **kwargs)
117
+ elif denied:
118
+ receipt = item.review.get('risk_receipt')
119
+ if not receipt:
120
+ raise RuntimeError('Risk review is incomplete. Refresh and review again.')
121
+ installed = manager.install_reviewed_commit(spec, target, force=True, risk_acknowledgment=receipt)
122
+ else:
123
+ installed = manager.install_reviewed_commit(spec, target, force=item.status == "REVIEW" and approve_review)
124
+ result.update(status="APPLIED", message=installed["commit"])
125
+ except Exception as exc:
126
+ result.update(status="FAILED", message=str(exc))
127
+ return results
128
+
129
+
130
+ def format_review(batch: UpdateBatch, title="Update all — review", for_ui=False, include_diff=True) -> str:
131
+ lines = [title, "Only installed, enabled, unpinned remote skills are eligible.",
132
+ "Updates apply independently. A failure does not undo earlier successes.",
133
+ "REVIEW skills require the approval checkbox (CLI: --force). DENY is never overridden.", ""]
134
+ if for_ui:
135
+ lines = [title, "Check the risk acknowledgment to include policy-denied updates. This does not change global policy.", ""]
136
+ if not batch.items:
137
+ lines.append("No installed skills to update.")
138
+ for item in batch.items:
139
+ status = ({'READY':'Ready to install', 'REVIEW':'Needs approval', 'BLOCKED':'Risk acknowledgment required',
140
+ 'FAILED':'Check failed', 'CURRENT':'Up to date', 'SKIPPED':'Skipped'}.get(item.status,item.status)
141
+ if for_ui else item.status)
142
+ lines.append(f"{item.name}: {status} — {item.message}")
143
+ for item in batch.items:
144
+ if item.status not in {"READY", "REVIEW", "BLOCKED"}:
145
+ continue
146
+ r = item.review
147
+ lines.extend(["", f"Review: {item.name}",
148
+ f"Commit: {(item.baseline or {}).get('commit', '-')} → {r['target_commit']}",
149
+ f"Policy: {r['policy_after']['decision']}"])
150
+ for reason in r['policy_after'].get('reasons', []):
151
+ lines.append(str(reason))
152
+ context = r.get('context_cost')
153
+ if context:
154
+ from .context_cost import display_tokens
155
+ before, after = context['before'], context['after']
156
+ lines.append(f"Estimated metadata tokens: {display_tokens(before['metadata_tokens'])} → {display_tokens(after['metadata_tokens'])}")
157
+ lines.append(f"Full instructions when used: {display_tokens(before['full_tokens'])} → {display_tokens(after['full_tokens'])} tokens")
158
+ audit = r.get('audit_after', {})
159
+ lines.append(f"Audit: HIGH {audit.get('high', 0)}, MEDIUM {audit.get('medium', 0)}, LOW {audit.get('low', 0)}")
160
+ for key, label in [('capabilities_added', 'Capabilities added'),
161
+ ('capabilities_removed', 'Capabilities removed'),
162
+ ('permission_drift_added', 'New permission drift')]:
163
+ lines.append(label + ': ' + (', '.join(r.get(key, [])) or 'none'))
164
+ lines.extend(str(x) for x in r.get('intent_delta', {}).get('summary', []))
165
+ for kind, paths in r.get('files', {}).items():
166
+ lines.append(f"Files {kind}: " + (', '.join(paths) or 'none'))
167
+ if include_diff and r.get('diff'):
168
+ lines.extend(['Diff:', r['diff']])
169
+ return "\n".join(lines)
170
+
171
+
172
+ def format_results(results: list[dict]) -> str:
173
+ return "Update results\n\n" + ("\n".join(f"{r['skill']}: {r['status']} — {r['message']}" for r in results) or "No updates applied.")
@@ -0,0 +1,196 @@
1
+ from textual import work
2
+ from textual.containers import Horizontal, Vertical, VerticalScroll
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Button, Checkbox, Static
5
+
6
+ from .bulk_update import apply_updates, format_results, format_review, review_updates
7
+
8
+
9
+ class BulkUpdateScreen(ModalScreen):
10
+ """Keep network work off the UI thread and evidence visible before approval."""
11
+ CSS = """
12
+ BulkUpdateScreen { align: center middle; }
13
+ #bulk-dialog { width: 90%; height: 90%; border: solid $accent; padding: 1; background: $surface; }
14
+ #bulk-evidence { height: 1fr; }
15
+ #bulk-summary { height: auto; padding-bottom: 1; }
16
+ #bulk-buttons { height: 3; }
17
+ #bulk-buttons Button { width: 1fr; min-width: 0; }
18
+ #bulk-approve { height: auto; }
19
+ #bulk-diff { height: 3; min-width: 0; }
20
+ """
21
+ BINDINGS = [("escape", "cancel", "Cancel")]
22
+
23
+ def __init__(self, manager, names=None, install_name=None, collection_name=None):
24
+ super().__init__()
25
+ self.manager = manager
26
+ self.names = names
27
+ self.install_name = install_name
28
+ self.collection_name = collection_name
29
+ self.install_label = install_name or ("collection " + collection_name if collection_name else None)
30
+ self.review_id = 0
31
+ self.closed = False
32
+ self.completed = False
33
+ self.show_diff = False
34
+ self.batch = None
35
+ self.applying = False
36
+
37
+ def compose(self):
38
+ with Vertical(id="bulk-dialog"):
39
+ yield Static("Review update" if self.names else "Review all updates", id="bulk-summary", markup=False)
40
+ with VerticalScroll(id="bulk-evidence"):
41
+ yield Static("Checking installed skills and preparing update reviews…", id="bulk-text", markup=False)
42
+ yield Button("Show file diff", id="bulk-diff", disabled=True)
43
+ yield Checkbox("I acknowledge the listed risks and choose to install", id="bulk-approve", disabled=True)
44
+ with Horizontal(id="bulk-buttons"):
45
+ yield Button("Cancel", id="bulk-cancel")
46
+ yield Button("Review again", id="bulk-retry", disabled=True)
47
+ yield Button("Install updates", id="bulk-apply", variant="success", disabled=True)
48
+
49
+ def on_mount(self):
50
+ self.owner_app = self.app
51
+ self.query_one('#bulk-cancel', Button).focus()
52
+ self.start_review()
53
+
54
+ def start_review(self):
55
+ self.review_id += 1
56
+ self.batch = None
57
+ self.completed = False
58
+ self.show_diff = False
59
+ self.query_one("#bulk-diff", Button).display = False
60
+ self.query_one("#bulk-diff", Button).disabled = True
61
+ self.query_one("#bulk-approve", Checkbox).display = False
62
+ self.query_one("#bulk-text", Static).update("Checking sources…")
63
+ self.query_one("#bulk-approve", Checkbox).value = False
64
+ self.query_one("#bulk-approve", Checkbox).disabled = True
65
+ self.query_one("#bulk-apply", Button).display = True
66
+ self.query_one("#bulk-apply", Button).disabled = True
67
+ self.query_one("#bulk-retry", Button).disabled = True
68
+ self.query_one("#bulk-summary", Static).update("Preparing a fresh review. No installation has started.")
69
+ self.query_one("#bulk-cancel", Button).label = "Cancel"
70
+ self.prepare(self.review_id)
71
+
72
+ @work(thread=True, group="bulk-review")
73
+ def prepare(self, review_id):
74
+ try:
75
+ if self.collection_name:
76
+ from .install_review import review_collection
77
+ batch = review_collection(self.manager, self.collection_name, progress=self.report_progress)
78
+ elif self.install_name:
79
+ from .install_review import review_install
80
+ batch = review_install(self.manager, self.install_name, progress=self.report_progress)
81
+ else:
82
+ batch = review_updates(self.manager, self.names, progress=self.report_progress)
83
+ self.owner_app.call_from_thread(self.show_review, batch, None, review_id)
84
+ except Exception as exc:
85
+ self.owner_app.call_from_thread(self.show_review, None, str(exc), review_id)
86
+
87
+ def report_progress(self, message):
88
+ self.owner_app.call_from_thread(self.show_progress, message)
89
+
90
+ def show_progress(self, message):
91
+ if not self.closed and self.is_mounted:
92
+ self.query_one('#bulk-text', Static).update(message)
93
+
94
+ def show_review(self, batch, error, review_id):
95
+ if self.closed or not self.is_mounted or review_id != self.review_id:
96
+ return
97
+ self.batch = batch
98
+ self.query_one("#bulk-retry", Button).disabled = False
99
+ if error:
100
+ self.query_one('#bulk-text', Static).update(error)
101
+ elif batch:
102
+ self.render_review()
103
+ if error:
104
+ self.query_one('#bulk-summary', Static).update('Unable to complete the review. Address the error below, then choose Review again.')
105
+ if batch:
106
+ self.query_one('#bulk-evidence', VerticalScroll).scroll_home(animate=False)
107
+ self.query_one('#bulk-approve', Checkbox).disabled = not any(i.status in {'REVIEW', 'BLOCKED'} for i in batch.items)
108
+ self.update_approval()
109
+
110
+ def render_review(self):
111
+ title = ("Review installation: " + self.install_label if self.install_label else
112
+ "Review update: " + self.names[0] if self.names else "Review all updates")
113
+ text = format_review(self.batch, title, for_ui=True, include_diff=self.show_diff)
114
+ if not self.batch.items:
115
+ text = ("All collection members and their dependencies are already installed." if self.collection_name else
116
+ "This skill and its dependencies are already installed." if self.install_name else
117
+ "No installed skills to update. Install a skill from Available to get started.")
118
+ self.query_one('#bulk-text', Static).update(text)
119
+ button = self.query_one('#bulk-diff', Button)
120
+ button.display = any(item.review.get('diff') for item in self.batch.items)
121
+ button.disabled = False
122
+ button.label = 'Hide file diff' if self.show_diff else 'Show file diff'
123
+ self.query_one('#bulk-evidence', VerticalScroll).scroll_home(animate=False)
124
+
125
+ def on_checkbox_changed(self, event: Checkbox.Changed):
126
+ if event.checkbox.id == 'bulk-approve' and self.batch and not self.applying and not self.completed:
127
+ self.update_approval()
128
+
129
+ def update_approval(self):
130
+ ready = sum(i.status == 'READY' for i in self.batch.items)
131
+ review = sum(bool(i.status == 'REVIEW' or (i.status == 'BLOCKED' and i.review.get('risk_receipt') and i.review.get('policy_after', {}).get('decision') == 'DENY')) for i in self.batch.items)
132
+ blocked = sum(i.status == 'BLOCKED' and not (i.review.get('risk_receipt') and i.review.get('policy_after', {}).get('decision') == 'DENY') for i in self.batch.items)
133
+ skipped = sum(i.status in {'CURRENT', 'SKIPPED'} for i in self.batch.items)
134
+ failed = sum(i.status == 'FAILED' for i in self.batch.items)
135
+ approved = self.query_one('#bulk-approve', Checkbox).value
136
+ count = ready + (review if approved else 0)
137
+ self.query_one('#bulk-summary', Static).update(
138
+ f"Ready: {ready} • Needs approval: {review} • Blocked: {blocked} • Check failed: {failed} • Unchanged: {skipped}\n"
139
+ + ("Acknowledge the listed risks below to enable installation. Global policy stays unchanged. " if review else "")
140
+ + (f"Selected for installation: {count}." if count else "No skills selected for installation."))
141
+ self.query_one('#bulk-approve', Checkbox).display = bool(review)
142
+ button = self.query_one('#bulk-apply', Button)
143
+ noun = 'skill' if self.install_label else 'update'
144
+ button.label = f'Install {noun}' if count == 1 else f'Install {count} {noun}s'
145
+ button.disabled = count == 0
146
+ button.tooltip = 'Installs only the exact commits shown in this review.'
147
+
148
+ def action_cancel(self):
149
+ if not self.applying:
150
+ self.closed = True
151
+ self.dismiss()
152
+
153
+ def on_button_pressed(self, event: Button.Pressed):
154
+ event.stop()
155
+ if event.button.id == 'bulk-cancel':
156
+ self.action_cancel()
157
+ elif event.button.id == 'bulk-diff' and self.batch and not self.applying and not self.completed:
158
+ self.show_diff = not self.show_diff
159
+ self.render_review()
160
+ elif event.button.id == 'bulk-retry' and not self.applying:
161
+ self.start_review()
162
+ elif event.button.id == 'bulk-apply' and self.batch and not self.applying:
163
+ self.query_one("#bulk-retry", Button).disabled = True
164
+ self.applying = True
165
+ self.query_one("#bulk-diff", Button).display = False
166
+ self.query_one('#bulk-apply', Button).disabled = True
167
+ self.query_one('#bulk-cancel', Button).disabled = True
168
+ checkbox = self.query_one('#bulk-approve', Checkbox)
169
+ checkbox.disabled = True
170
+ self.query_one('#bulk-summary', Static).update('Installing reviewed skills… Please wait for the results.')
171
+ self.query_one('#bulk-text', Static).update('Applying reviewed commits…')
172
+ self.apply_batch(checkbox.value)
173
+
174
+ @work(thread=True, group="bulk-apply")
175
+ def apply_batch(self, approved):
176
+ try:
177
+ message = format_results(apply_updates(self.manager, self.batch, approve_review=approved, acknowledge_risk=approved, progress=self.report_progress))
178
+ except Exception as exc:
179
+ message = 'Installation failed: ' + str(exc)
180
+ if self.install_label:
181
+ message = message.replace('Update results', 'Installation results', 1).replace('No updates applied.', 'No new skills installed.')
182
+ self.owner_app.call_from_thread(self.show_results, message)
183
+
184
+ def show_results(self, message):
185
+ self.applying = False
186
+ self.completed = True
187
+ self.query_one("#bulk-retry", Button).disabled = False
188
+ self.query_one("#bulk-summary", Static).update("Installation attempt finished. Check each result below; failed or blocked skills were not reported as installed.")
189
+ self.query_one("#bulk-approve", Checkbox).display = False
190
+ self.query_one("#bulk-apply", Button).display = False
191
+ self.query_one("#bulk-evidence", VerticalScroll).scroll_home(animate=False)
192
+ self.query_one('#bulk-text', Static).update(message)
193
+ button = self.query_one('#bulk-cancel', Button)
194
+ button.label = 'Close'
195
+ button.disabled = False
196
+ button.focus()
skillctl/cacheutil.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ def cache_stats(root:Path|None=None)->dict:
6
+ root=root or Path("~/.skillctl/cache").expanduser()
7
+ result={"root":str(root),"total_files":0,"total_bytes":0,"evidence_files":0,"stage_files":0}
8
+ if not root.exists(): return result
9
+ for p in root.rglob("*"):
10
+ if not p.is_file(): continue
11
+ result["total_files"]+=1
12
+ try: result["total_bytes"]+=p.stat().st_size
13
+ except OSError: pass
14
+ rel=str(p.relative_to(root))
15
+ if rel.startswith("evidence/"): result["evidence_files"]+=1
16
+ if rel.startswith("stages/"): result["stage_files"]+=1
17
+ return result
18
+
19
+ def clear_cache(root:Path|None=None)->None:
20
+ root=root or Path("~/.skillctl/cache").expanduser()
21
+ if root.exists(): shutil.rmtree(root)