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.
- skillctl/__init__.py +1 -0
- skillctl/__main__.py +5 -0
- skillctl/authcontrols.py +165 -0
- skillctl/benchmark.py +150 -0
- skillctl/browsercontrols.py +200 -0
- skillctl/bulk_cli.py +66 -0
- skillctl/bulk_plan.py +96 -0
- skillctl/bulk_update.py +173 -0
- skillctl/bulk_update_tui.py +196 -0
- skillctl/cacheutil.py +21 -0
- skillctl/candidates.py +244 -0
- skillctl/catalog.py +48 -0
- skillctl/catalog_discovery.py +67 -0
- skillctl/catalog_index.py +195 -0
- skillctl/catalog_schema.py +58 -0
- skillctl/catalog_search_cli.py +100 -0
- skillctl/catalog_search_tui.py +188 -0
- skillctl/cicdflow.py +89 -0
- skillctl/cli.py +958 -0
- skillctl/cli_contract.py +71 -0
- skillctl/compatibility.py +283 -0
- skillctl/context_cost.py +90 -0
- skillctl/context_tui.py +84 -0
- skillctl/copilot_export.py +188 -0
- skillctl/coverage.py +11 -0
- skillctl/dedupe.py +14 -0
- skillctl/doctor.py +113 -0
- skillctl/entrypoint.py +207 -0
- skillctl/environments.py +193 -0
- skillctl/estimate.py +136 -0
- skillctl/evidence.py +133 -0
- skillctl/findings.py +37 -0
- skillctl/flow.py +126 -0
- skillctl/governor.py +50 -0
- skillctl/hardening.py +195 -0
- skillctl/history_tui.py +149 -0
- skillctl/iac.py +95 -0
- skillctl/install_intent.py +148 -0
- skillctl/install_review.py +58 -0
- skillctl/intent_diff.py +97 -0
- skillctl/journal.py +157 -0
- skillctl/journal_hooks.py +37 -0
- skillctl/journal_operations.py +148 -0
- skillctl/lockfile.py +59 -0
- skillctl/manager.py +1533 -0
- skillctl/models.py +56 -0
- skillctl/navigation_tui.py +92 -0
- skillctl/operation_lock.py +111 -0
- skillctl/packet.py +46 -0
- skillctl/paths.py +35 -0
- skillctl/permissions.py +20 -0
- skillctl/policy.py +96 -0
- skillctl/policy_editor.py +146 -0
- skillctl/precision.py +197 -0
- skillctl/profiles.py +89 -0
- skillctl/publishers.py +25 -0
- skillctl/ranking.py +87 -0
- skillctl/recommendation.py +153 -0
- skillctl/recommendation_tui.py +351 -0
- skillctl/reconcile_cli.py +181 -0
- skillctl/reconcile_recovery.py +267 -0
- skillctl/reconcile_transaction.py +274 -0
- skillctl/reconcile_tui.py +335 -0
- skillctl/reconciliation.py +253 -0
- skillctl/remote.py +163 -0
- skillctl/resources/benchmarks/vulnlab/answer-key.yaml +122 -0
- skillctl/resources/benchmarks/vulnlab/target/.env +2 -0
- skillctl/resources/benchmarks/vulnlab/target/.github/workflows/build.yml +19 -0
- skillctl/resources/benchmarks/vulnlab/target/README.md +6 -0
- skillctl/resources/benchmarks/vulnlab/target/app.py +35 -0
- skillctl/resources/benchmarks/vulnlab/target/openapi.yaml +6 -0
- skillctl/resources/benchmarks/vulnlab/target/requirements.txt +2 -0
- skillctl/resources/benchmarks/vulnlab/target/scripts/build.sh +4 -0
- skillctl/resources/benchmarks/vulnlab/target/templates/payment.html +4 -0
- skillctl/resources/benchmarks/vulnlab/target/trivy-results.json +16 -0
- skillctl/resources/custom/.gitkeep +0 -0
- skillctl/resources/custom/api-security-review/SKILL.md +149 -0
- skillctl/resources/custom/api-security-review/references/inventory-template.md +8 -0
- skillctl/resources/custom/api-security-review/references/output-template.md +33 -0
- skillctl/resources/custom/biso-security-review/SKILL.md +135 -0
- skillctl/resources/custom/biso-security-review/references/disposition.md +15 -0
- skillctl/resources/custom/biso-security-review/references/orchestration.md +46 -0
- skillctl/resources/custom/biso-security-review/references/output-template.md +55 -0
- skillctl/resources/custom/biso-security-review/references/review-checklist.md +36 -0
- skillctl/resources/custom/biso-security-review/references/risk-rating.md +16 -0
- skillctl/resources/custom/cicd-security-review/SKILL.md +133 -0
- skillctl/resources/custom/cicd-security-review/references/output-template.md +27 -0
- skillctl/resources/custom/pci-architect/SKILL.md +109 -0
- skillctl/resources/custom/pci-architect/references/control-matrix.md +12 -0
- skillctl/resources/custom/pci-architect/references/output-template.md +44 -0
- skillctl/resources/custom/threat-model/SKILL.md +69 -0
- skillctl/resources/custom/threat-model/references/output-template.md +44 -0
- skillctl/resources/custom/threat-model/references/risk-rating.md +25 -0
- skillctl/resources/custom/vulnerability-manager/SKILL.md +132 -0
- skillctl/resources/custom/vulnerability-manager/references/escalation.md +18 -0
- skillctl/resources/custom/vulnerability-manager/references/output-template.md +21 -0
- skillctl/resources/custom/vulnerability-manager/references/prioritization.md +36 -0
- skillctl/resources/finding-schema.json +131 -0
- skillctl/resources/skills.yaml +1907 -0
- skillctl/review.py +495 -0
- skillctl/review_candidates.py +43 -0
- skillctl/risk_acknowledgment.py +33 -0
- skillctl/runtime_binding.py +141 -0
- skillctl/safety_plan.py +156 -0
- skillctl/section_navigation.py +37 -0
- skillctl/stagecache.py +92 -0
- skillctl/stateio.py +55 -0
- skillctl/transactions.py +131 -0
- skillctl/trust.py +72 -0
- skillctl/tui.py +304 -0
- skillctl/tui_interaction.py +125 -0
- skillctl/tui_original.py +1646 -0
- skillctl/tui_progressive.py +102 -0
- skillctl/tui_refresh.py +286 -0
- skillctl/update_intent_review.py +74 -0
- skillctl/update_intent_tui.py +57 -0
- skillctl/vulnnormalize.py +114 -0
- skillctl_secure-0.28.0.dist-info/METADATA +292 -0
- skillctl_secure-0.28.0.dist-info/RECORD +122 -0
- skillctl_secure-0.28.0.dist-info/WHEEL +4 -0
- skillctl_secure-0.28.0.dist-info/entry_points.txt +2 -0
- 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"]
|
skillctl/bulk_update.py
ADDED
|
@@ -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)
|