cct-cli 0.7.9.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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CCT — packages.py: Autonomous Package Manager (v0.7.7 spec section 1/10/11/12).
|
|
3
|
+
|
|
4
|
+
Lets the user (or the AI, in Build mode) install real packages from chat:
|
|
5
|
+
|
|
6
|
+
"install requests" -> pip
|
|
7
|
+
"install numpy" -> pip
|
|
8
|
+
"install latest opencv" -> pip, pinned to the latest release
|
|
9
|
+
"install nodejs" -> system package manager (winget/choco/brew/apt/dnf)
|
|
10
|
+
"install rust" -> rustup (if present) or the system package manager
|
|
11
|
+
"install docker" -> system package manager
|
|
12
|
+
"install cuda" -> system package manager
|
|
13
|
+
"install lodash with npm" -> npm (explicit manager hint honored)
|
|
14
|
+
"/install torch --manager pipx"
|
|
15
|
+
|
|
16
|
+
Every supported package manager (pip, pipx, uv, conda, npm, pnpm, yarn,
|
|
17
|
+
bun, cargo, go, composer, vcpkg, chocolatey, winget, apt, dnf, brew) maps
|
|
18
|
+
to a ManagerSpec with real command builders and a best-effort verify step.
|
|
19
|
+
|
|
20
|
+
Honest scope, stated plainly:
|
|
21
|
+
* "Install" means: run the package manager's real install command in a
|
|
22
|
+
subprocess on the user's machine. It never happens silently — the
|
|
23
|
+
permission gate lives in the callers (calc_terminal/app.py,
|
|
24
|
+
calc_terminal/ui/app.py, calc_terminal/agent.py), and every stage of
|
|
25
|
+
the run is reported through an `on_event` callback so the UI can
|
|
26
|
+
render the Installation Dashboard (spec section 11).
|
|
27
|
+
* Manager detection is heuristic (keyword + platform tables), not a
|
|
28
|
+
semantic package database. When a package is genuinely ambiguous or
|
|
29
|
+
unknown, the caller is expected to run the research flow
|
|
30
|
+
(package_research.py) BEFORE installing — this module only decides
|
|
31
|
+
on the *known* mappings and says "unknown" honestly otherwise.
|
|
32
|
+
* Real download/install progress bars from e.g. pip/npm live on the
|
|
33
|
+
child process's stdout/stderr; this module streams those lines
|
|
34
|
+
through verbatim rather than fabricating percentages.
|
|
35
|
+
* sudo-requiring managers (apt/dnf) build the `sudo ...` command and
|
|
36
|
+
pass the user's permission dialog through first; the sudo password
|
|
37
|
+
prompt itself happens in the user's real terminal when the command
|
|
38
|
+
runs, never captured here.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
print("This is a library file and is not meant to be run directly.")
|
|
43
|
+
import sys
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import shutil
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
from . import eventbus
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ events --
|
|
57
|
+
# Installation Dashboard event vocabulary. Every event is a plain dict:
|
|
58
|
+
# {"stage": <one of the keys below>, "message": str, "detail": optional str}
|
|
59
|
+
# Callers render these; this module never prints them itself (the classic
|
|
60
|
+
# terminal and the Textual UI each have their own renderer).
|
|
61
|
+
STAGE_RESOLVING = "resolving"
|
|
62
|
+
STAGE_PERMISSION = "permission"
|
|
63
|
+
STAGE_DOWNLOAD = "download"
|
|
64
|
+
STAGE_RUNNING = "running"
|
|
65
|
+
STAGE_VERIFYING = "verifying"
|
|
66
|
+
STAGE_DONE = "done"
|
|
67
|
+
STAGE_FAILED = "failed"
|
|
68
|
+
STAGE_SKIPPED = "skipped"
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- managers --
|
|
71
|
+
# name -> (label, install-cmd builder, verify hint). `platforms` is the
|
|
72
|
+
# OS list the manager is native to ("" = any). The install builders take
|
|
73
|
+
# (package_name, version) and return the argv (list) to run — lists, not
|
|
74
|
+
# shell strings, so no quoting surprises reach a shell.
|
|
75
|
+
|
|
76
|
+
_WIN = os.name == "nt"
|
|
77
|
+
_MAC = sys.platform == "darwin"
|
|
78
|
+
_LINUX = sys.platform.startswith("linux")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _py():
|
|
82
|
+
"""The python interpreter this app is running on (used for pip
|
|
83
|
+
invocation, so 'install requests' installs into the environment
|
|
84
|
+
CCT itself runs in)."""
|
|
85
|
+
return sys.executable or "python"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _pip_install(name, version, target=None):
|
|
89
|
+
cmd = [_py(), "-m", "pip", "install", "--no-input"]
|
|
90
|
+
if version and version != "latest":
|
|
91
|
+
cmd.append(f"{name}=={version}")
|
|
92
|
+
else:
|
|
93
|
+
cmd.append(name)
|
|
94
|
+
return cmd
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _npm_install(name, version, target=None):
|
|
98
|
+
cmd = ["npm", "install", "-g", name] if target != "local" else ["npm", "install", name]
|
|
99
|
+
return cmd
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _apt_install(name, version, target=None):
|
|
103
|
+
return ["sudo", "apt-get", "install", "-y", name]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _dnf_install(name, version, target=None):
|
|
107
|
+
return ["sudo", "dnf", "install", "-y", name]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
MANAGERS = {
|
|
111
|
+
"pip": dict(
|
|
112
|
+
label="pip (Python)",
|
|
113
|
+
icon="\U0001f40d",
|
|
114
|
+
platforms="",
|
|
115
|
+
install=_pip_install,
|
|
116
|
+
verify=lambda name, version: [_py(), "-m", "pip", "show", name],
|
|
117
|
+
check_import=True,
|
|
118
|
+
),
|
|
119
|
+
"pipx": dict(
|
|
120
|
+
label="pipx (Python apps)",
|
|
121
|
+
icon="\U0001f40d",
|
|
122
|
+
platforms="",
|
|
123
|
+
install=lambda name, version, target=None: ["pipx", "install", name],
|
|
124
|
+
verify=lambda name, version: ["pipx", "list"],
|
|
125
|
+
check_import=False,
|
|
126
|
+
),
|
|
127
|
+
"uv": dict(
|
|
128
|
+
label="uv (Rust-based pip)",
|
|
129
|
+
icon="\u26a1",
|
|
130
|
+
platforms="",
|
|
131
|
+
install=lambda name, version, target=None: ["uv", "pip", "install", name],
|
|
132
|
+
verify=lambda name, version: ["uv", "pip", "show", name],
|
|
133
|
+
check_import=True,
|
|
134
|
+
),
|
|
135
|
+
"conda": dict(
|
|
136
|
+
label="conda (Anaconda)",
|
|
137
|
+
icon="\U0001f331",
|
|
138
|
+
platforms="",
|
|
139
|
+
install=lambda name, version, target=None: ["conda", "install", "-y", name],
|
|
140
|
+
verify=lambda name, version: ["conda", "list", name],
|
|
141
|
+
check_import=True,
|
|
142
|
+
),
|
|
143
|
+
"npm": dict(
|
|
144
|
+
label="npm (Node.js)",
|
|
145
|
+
icon="\U0001f4e6",
|
|
146
|
+
platforms="",
|
|
147
|
+
install=_npm_install,
|
|
148
|
+
verify=lambda name, version: ["npm", "ls", "-g", name],
|
|
149
|
+
check_import=False,
|
|
150
|
+
),
|
|
151
|
+
"pnpm": dict(
|
|
152
|
+
label="pnpm (Node.js)",
|
|
153
|
+
icon="\U0001f4e6",
|
|
154
|
+
platforms="",
|
|
155
|
+
install=lambda name, version, target=None: (
|
|
156
|
+
["pnpm", "add", "-g", name] if target != "local" else ["pnpm", "add", name]),
|
|
157
|
+
verify=lambda name, version: ["pnpm", "ls", "-g", name],
|
|
158
|
+
check_import=False,
|
|
159
|
+
),
|
|
160
|
+
"yarn": dict(
|
|
161
|
+
label="yarn (Node.js)",
|
|
162
|
+
icon="\U0001f4e6",
|
|
163
|
+
platforms="",
|
|
164
|
+
install=lambda name, version, target=None: (
|
|
165
|
+
["yarn", "global", "add", name] if target != "local" else ["yarn", "add", name]),
|
|
166
|
+
verify=lambda name, version: ["yarn", "global", "list"],
|
|
167
|
+
check_import=False,
|
|
168
|
+
),
|
|
169
|
+
"bun": dict(
|
|
170
|
+
label="bun (Node.js runtime)",
|
|
171
|
+
icon="\U0001f35c",
|
|
172
|
+
platforms="",
|
|
173
|
+
install=lambda name, version, target=None: (
|
|
174
|
+
["bun", "add", "-g", name] if target != "local" else ["bun", "add", name]),
|
|
175
|
+
verify=lambda name, version: ["bun", "pm", "ls", "-g"],
|
|
176
|
+
check_import=False,
|
|
177
|
+
),
|
|
178
|
+
"cargo": dict(
|
|
179
|
+
label="cargo (Rust crates)",
|
|
180
|
+
icon="\U0001f680",
|
|
181
|
+
platforms="",
|
|
182
|
+
install=lambda name, version, target=None: ["cargo", "install", name],
|
|
183
|
+
verify=lambda name, version: ["cargo", "install", "--list"],
|
|
184
|
+
check_import=False,
|
|
185
|
+
),
|
|
186
|
+
"go": dict(
|
|
187
|
+
label="go (Go modules)",
|
|
188
|
+
icon="\U0001f4a0",
|
|
189
|
+
platforms="",
|
|
190
|
+
install=lambda name, version, target=None: ["go", "install", f"{name}@latest"],
|
|
191
|
+
verify=lambda name, version: ["go", "list", "-m", name],
|
|
192
|
+
check_import=False,
|
|
193
|
+
),
|
|
194
|
+
"composer": dict(
|
|
195
|
+
label="composer (PHP)",
|
|
196
|
+
icon="\U0001f4e6",
|
|
197
|
+
platforms="",
|
|
198
|
+
install=lambda name, version, target=None: ["composer", "global", "require", name],
|
|
199
|
+
verify=lambda name, version: ["composer", "global", "show", name],
|
|
200
|
+
check_import=False,
|
|
201
|
+
),
|
|
202
|
+
"vcpkg": dict(
|
|
203
|
+
label="vcpkg (C/C++)",
|
|
204
|
+
icon="\U0001f6e0\ufe0f",
|
|
205
|
+
platforms="",
|
|
206
|
+
install=lambda name, version, target=None: ["vcpkg", "install", name],
|
|
207
|
+
verify=lambda name, version: ["vcpkg", "list", name],
|
|
208
|
+
check_import=False,
|
|
209
|
+
),
|
|
210
|
+
"chocolatey": dict(
|
|
211
|
+
label="chocolatey (Windows)",
|
|
212
|
+
icon="\U0001f36b",
|
|
213
|
+
platforms="win",
|
|
214
|
+
install=lambda name, version, target=None: ["choco", "install", "-y", name],
|
|
215
|
+
verify=lambda name, version: ["choco", "list", name, "--local-only"],
|
|
216
|
+
check_import=False,
|
|
217
|
+
),
|
|
218
|
+
"winget": dict(
|
|
219
|
+
label="winget (Windows)",
|
|
220
|
+
icon="\U0001f310",
|
|
221
|
+
platforms="win",
|
|
222
|
+
install=lambda name, version, target=None: [
|
|
223
|
+
"winget", "install", "--silent",
|
|
224
|
+
"--accept-package-agreements", "--accept-source-agreements", name],
|
|
225
|
+
verify=lambda name, version: ["winget", "list", name],
|
|
226
|
+
check_import=False,
|
|
227
|
+
),
|
|
228
|
+
"apt": dict(
|
|
229
|
+
label="apt (Debian/Ubuntu)",
|
|
230
|
+
icon="\U0001f3e2",
|
|
231
|
+
platforms="linux",
|
|
232
|
+
install=_apt_install,
|
|
233
|
+
verify=lambda name, version: ["apt", "list", "--installed", name + "*"],
|
|
234
|
+
check_import=False,
|
|
235
|
+
),
|
|
236
|
+
"dnf": dict(
|
|
237
|
+
label="dnf (Fedora/RHEL)",
|
|
238
|
+
icon="\U0001f3e2",
|
|
239
|
+
platforms="linux",
|
|
240
|
+
install=_dnf_install,
|
|
241
|
+
verify=lambda name, version: ["dnf", "list", "--installed", name],
|
|
242
|
+
check_import=False,
|
|
243
|
+
),
|
|
244
|
+
"brew": dict(
|
|
245
|
+
label="homebrew (macOS/Linux)",
|
|
246
|
+
icon="\U0001f37a",
|
|
247
|
+
platforms="darwin",
|
|
248
|
+
install=lambda name, version, target=None: ["brew", "install", name],
|
|
249
|
+
verify=lambda name, version: ["brew", "list", name],
|
|
250
|
+
check_import=False,
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Python package -> importable module name (for the import-succeeds
|
|
255
|
+
# verification step of spec section 12). Only the common renames; a
|
|
256
|
+
# package whose import name matches its install name uses itself.
|
|
257
|
+
_IMPORT_ALIASES = {
|
|
258
|
+
"opencv-python": "cv2",
|
|
259
|
+
"opencv-python-headless": "cv2",
|
|
260
|
+
"Pillow": "PIL",
|
|
261
|
+
"PyYAML": "yaml",
|
|
262
|
+
"beautifulsoup4": "bs4",
|
|
263
|
+
"scikit-learn": "sklearn",
|
|
264
|
+
"scikit-image": "skimage",
|
|
265
|
+
"python-dateutil": "dateutil",
|
|
266
|
+
"requests-html": "requests_html",
|
|
267
|
+
"psycopg2-binary": "psycopg2",
|
|
268
|
+
"pytz": "pytz",
|
|
269
|
+
"numpy": "numpy",
|
|
270
|
+
"torch": "torch",
|
|
271
|
+
"tensorflow": "tensorflow",
|
|
272
|
+
"matplotlib": "matplotlib",
|
|
273
|
+
"sympy": "sympy",
|
|
274
|
+
"pandas": "pandas",
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Known-language package tables (for manager detection). These are the
|
|
278
|
+
# honest heuristics behind "install requests" -> pip: a curated list of
|
|
279
|
+
# well-known packages per ecosystem, not a live index.
|
|
280
|
+
PYTHON_PACKAGES = {
|
|
281
|
+
"requests", "numpy", "pandas", "scipy", "sympy", "matplotlib", "seaborn",
|
|
282
|
+
"scikit-learn", "scikit-image", "torch", "tensorflow", "keras", "jax",
|
|
283
|
+
"transformers", "torchvision", "torchaudio", "opencv-python",
|
|
284
|
+
"opencv-python-headless", "pillow", "pyyaml", "flask", "django",
|
|
285
|
+
"fastapi", "uvicorn", "pytest", "black", "ruff", "isort", "mypy",
|
|
286
|
+
"beautifulsoup4", "requests-html", "selenium", "playwright", "httpx",
|
|
287
|
+
"aiohttp", "websockets", "tqdm", "click", "typer", "rich", "textual",
|
|
288
|
+
"prompt-toolkit", "colorama", "boto3", "gunicorn", "celery", "redis",
|
|
289
|
+
"pymongo", "psycopg2-binary", "sqlalchemy", "alembic", "pydantic",
|
|
290
|
+
"pydantic-settings", "pyqt5", "pyside6", "tkinter", "jupyter",
|
|
291
|
+
"notebook", "ipykernel", "ipython", "nltk", "spacy", "gensim",
|
|
292
|
+
"xgboost", "lightgbm", "statsmodels", "polars", "pyarrow", "dask",
|
|
293
|
+
"joblib", "bokeh", "plotly", "kaleido", "networkx", "biopython",
|
|
294
|
+
"rdkit", "pubchempy", "ase", "pymatgen", "openpyxl", "xlrd", "xlsxwriter",
|
|
295
|
+
"pdfplumber", "pypdf", "fitz", "pyinstaller", "pip", "pipx", "uv",
|
|
296
|
+
}
|
|
297
|
+
NODE_PACKAGES = {
|
|
298
|
+
"lodash", "express", "react", "react-dom", "vue", "next", "nuxt", "svelte",
|
|
299
|
+
"typescript", "ts-node", "nodemon", "axios", "node-fetch", "moment",
|
|
300
|
+
"chalk", "commander", "yargs", "dotenv", "cors", "body-parser",
|
|
301
|
+
"socket.io", "ws", "jquery", "eslint", "prettier", "jest", "mocha",
|
|
302
|
+
"vitest", "vite", "webpack", "babel", "prisma", "mongoose", "pg",
|
|
303
|
+
"mysql2", "sequelize", "knex", "graphql", "apollo-server", "next-auth",
|
|
304
|
+
"tailwindcss", "zustand", "redux", "react-router-dom", "uuid",
|
|
305
|
+
"express-session", "passport", "bcrypt", "jsonwebtoken", "zod",
|
|
306
|
+
"yup", "formik", "framer-motion", "three", "d3", "chart.js",
|
|
307
|
+
"electron", "expo", "react-native", "firebase", "stripe",
|
|
308
|
+
}
|
|
309
|
+
RUST_CRATES = {
|
|
310
|
+
"serde", "tokio", "rayon", "clap", "anyhow", "thiserror", "reqwest",
|
|
311
|
+
"hyper", "axum", "actix-web", "rocket", "tokio-postgres", "sqlx",
|
|
312
|
+
"diesel", "rusqlite", "rand", "chrono", "regex", "lazy_static",
|
|
313
|
+
"once_cell", "log", "env_logger", "tracing", "serde_json", "tokio-util",
|
|
314
|
+
"futures", "async-std", "tempfile", "walkdir", "glob", "dirs",
|
|
315
|
+
"csv", "serde_yaml", "toml", "jsonwebtoken", "rpassword", "indicatif",
|
|
316
|
+
"crossterm", "ratatui", "tui", "egui", "iced", "bevy", "nalgebra",
|
|
317
|
+
"ndarray", "num", "itertools", "uuid", "indexmap", "hashbrown",
|
|
318
|
+
"parking_lot", "dashmap", "crossbeam", "flume", "crossbeam-channel",
|
|
319
|
+
}
|
|
320
|
+
GO_MODULES = {
|
|
321
|
+
"gin", "echo", "chi", "mux", "gorilla/mux", "cobra", "viper", "zap",
|
|
322
|
+
"logrus", "gorm", "sqlx", "pgx", "fiber", "fasthttp", "grpc",
|
|
323
|
+
"protobuf", "uuid", "jwt", "casbin", "hugo", "docker", "go-git",
|
|
324
|
+
}
|
|
325
|
+
PHP_PACKAGES = {
|
|
326
|
+
"laravel/framework", "symfony/console", "monolog/monolog", "guzzlehttp/guzzle",
|
|
327
|
+
"phpunit/phpunit", "ramsey/uuid", "vlucas/phpdotenv", "phpoffice/phpspreadsheet",
|
|
328
|
+
"composer/semver", "nesbot/carbon",
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# Well-known standalone tools -> (manager candidates in order, verify binary)
|
|
332
|
+
SYSTEM_TOOLS = {
|
|
333
|
+
"nodejs": (["winget", "chocolatey", "brew", "apt", "dnf"], "node"),
|
|
334
|
+
"node": (["winget", "chocolatey", "brew", "apt", "dnf"], "node"),
|
|
335
|
+
"npm": (["winget", "chocolatey", "brew", "apt", "dnf"], "npm"),
|
|
336
|
+
"rust": (["rustup"], "rustc"),
|
|
337
|
+
"rustup": (["winget", "chocolatey", "brew", "apt", "dnf"], "rustup"),
|
|
338
|
+
"rustc": (["rustup"], "rustc"),
|
|
339
|
+
"cargo": (["rustup"], "cargo"),
|
|
340
|
+
"docker": (["winget", "chocolatey", "brew", "apt", "dnf"], "docker"),
|
|
341
|
+
"docker desktop": (["winget", "chocolatey", "brew"], "docker"),
|
|
342
|
+
"cuda": (["winget", "chocolatey", "brew"], "nvcc"),
|
|
343
|
+
"cuda toolkit": (["winget", "chocolatey"], "nvcc"),
|
|
344
|
+
"go": (["winget", "chocolatey", "brew", "apt", "dnf"], "go"),
|
|
345
|
+
"golang": (["winget", "chocolatey", "brew", "apt", "dnf"], "go"),
|
|
346
|
+
"java": (["winget", "chocolatey", "brew", "apt", "dnf"], "java"),
|
|
347
|
+
"jdk": (["winget", "chocolatey", "brew", "apt", "dnf"], "java"),
|
|
348
|
+
"python": (["winget", "chocolatey", "brew", "apt", "dnf"], "python"),
|
|
349
|
+
"ffmpeg": (["winget", "chocolatey", "brew", "apt", "dnf"], "ffmpeg"),
|
|
350
|
+
"git": (["winget", "chocolatey", "brew", "apt", "dnf"], "git"),
|
|
351
|
+
"curl": (["winget", "chocolatey", "brew", "apt", "dnf"], "curl"),
|
|
352
|
+
"wget": (["winget", "chocolatey", "brew", "apt", "dnf"], "wget"),
|
|
353
|
+
"vim": (["winget", "chocolatey", "brew", "apt", "dnf"], "vim"),
|
|
354
|
+
"neovim": (["winget", "chocolatey", "brew", "apt", "dnf"], "nvim"),
|
|
355
|
+
"vscode": (["winget", "chocolatey", "brew"], "code"),
|
|
356
|
+
"git-lfs": (["winget", "chocolatey", "brew", "apt", "dnf"], "git-lfs"),
|
|
357
|
+
"kubernetes": (["winget", "chocolatey", "brew", "apt", "dnf"], "kubectl"),
|
|
358
|
+
"kubectl": (["winget", "chocolatey", "brew", "apt", "dnf"], "kubectl"),
|
|
359
|
+
"helm": (["winget", "chocolatey", "brew", "apt", "dnf"], "helm"),
|
|
360
|
+
"terraform": (["winget", "chocolatey", "brew", "apt", "dnf"], "terraform"),
|
|
361
|
+
"aws cli": (["winget", "chocolatey", "brew"], "aws"),
|
|
362
|
+
"postgresql": (["winget", "chocolatey", "brew", "apt", "dnf"], "psql"),
|
|
363
|
+
"mysql": (["winget", "chocolatey", "brew", "apt", "dnf"], "mysql"),
|
|
364
|
+
"sqlite": (["winget", "chocolatey", "brew", "apt", "dnf"], "sqlite3"),
|
|
365
|
+
"mongodb": (["winget", "chocolatey", "brew"], "mongod"),
|
|
366
|
+
"redis": (["winget", "chocolatey", "brew", "apt", "dnf"], "redis-server"),
|
|
367
|
+
"nginx": (["winget", "chocolatey", "brew", "apt", "dnf"], "nginx"),
|
|
368
|
+
"apache": (["winget", "chocolatey", "brew", "apt", "dnf"], "httpd"),
|
|
369
|
+
"openssl": (["winget", "chocolatey", "brew", "apt", "dnf"], "openssl"),
|
|
370
|
+
"7zip": (["winget", "chocolatey"], "7z"),
|
|
371
|
+
"vlc": (["winget", "chocolatey", "brew"], "vlc"),
|
|
372
|
+
"gimp": (["winget", "chocolatey", "brew", "apt", "dnf"], "gimp"),
|
|
373
|
+
"inkscape": (["winget", "chocolatey", "brew", "apt", "dnf"], "inkscape"),
|
|
374
|
+
"blender": (["winget", "chocolatey", "brew", "apt", "dnf"], "blender"),
|
|
375
|
+
"arduino": (["winget", "chocolatey", "brew"], "arduino-cli"),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# C/C++ libraries commonly installed via vcpkg on Windows.
|
|
379
|
+
_VCPKG_LIBS = {
|
|
380
|
+
"boost", "openssl", "zlib", "sqlite3", "curl", "libcurl", "fmt",
|
|
381
|
+
"spdlog", "gtest", "cpr", "nlohmann-json", "eigen3", "opencv",
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------ request model --
|
|
385
|
+
class PackageRequest:
|
|
386
|
+
"""One parsed install request. `manager` may be None (meaning
|
|
387
|
+
'unknown — research first'), `version` defaults to 'latest'."""
|
|
388
|
+
|
|
389
|
+
__slots__ = ("name", "version", "manager", "manager_hint", "target", "raw")
|
|
390
|
+
|
|
391
|
+
def __init__(self, name, version="latest", manager=None, manager_hint=None,
|
|
392
|
+
target=None, raw=""):
|
|
393
|
+
self.name = name
|
|
394
|
+
self.version = version or "latest"
|
|
395
|
+
self.manager = manager
|
|
396
|
+
self.manager_hint = manager_hint
|
|
397
|
+
self.target = target # None | "local" (npm-local etc.)
|
|
398
|
+
self.raw = raw
|
|
399
|
+
|
|
400
|
+
def label(self):
|
|
401
|
+
return f"{self.name}@{self.version}" if self.version != "latest" else self.name
|
|
402
|
+
|
|
403
|
+
def __repr__(self):
|
|
404
|
+
return f"PackageRequest({self.label()!r}, manager={self.manager!r})"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------- parsing --
|
|
408
|
+
_MANAGER_WORDS = {
|
|
409
|
+
"pip": "pip", "pipx": "pipx", "uv": "uv", "conda": "conda",
|
|
410
|
+
"npm": "npm", "pnpm": "pnpm", "yarn": "yarn", "bun": "bun",
|
|
411
|
+
"cargo": "cargo", "go": "go", "composer": "composer",
|
|
412
|
+
"vcpkg": "vcpkg", "chocolatey": "choco", "choco": "chocolatey",
|
|
413
|
+
"winget": "winget", "apt": "apt", "apt-get": "apt",
|
|
414
|
+
"dnf": "dnf", "brew": "brew", "homebrew": "brew",
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_INSTALL_RE = re.compile(
|
|
418
|
+
r"\b(?:install|setup|get)\b.*?\b(\S+)\b", re.I)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def parse_request(text):
|
|
422
|
+
"""Parse free text like 'install latest opencv' / 'Install numpy with
|
|
423
|
+
pip' / 'install NodeJS' into a PackageRequest (manager may be None).
|
|
424
|
+
Returns None if the text doesn't look like an install request at
|
|
425
|
+
all."""
|
|
426
|
+
if not text:
|
|
427
|
+
return None
|
|
428
|
+
low = text.lower().strip()
|
|
429
|
+
m = re.search(r"\b(?:install|setup|get)\b", low)
|
|
430
|
+
if not m:
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
# Explicit manager mention: "install X with pip", "pip install X",
|
|
434
|
+
# "install X using npm" ...
|
|
435
|
+
manager_hint = None
|
|
436
|
+
for word, manager in _MANAGER_WORDS.items():
|
|
437
|
+
if re.search(rf"\b{re.escape(word)}\b", low):
|
|
438
|
+
manager_hint = manager
|
|
439
|
+
break
|
|
440
|
+
|
|
441
|
+
# The package name: first word after the install/setup/get verb.
|
|
442
|
+
rest = low[m.end():]
|
|
443
|
+
rest = rest.replace("latest", " ").strip()
|
|
444
|
+
name_match = re.search(r"^\s*([a-z0-9][a-z0-9+_.@/-]*)", rest)
|
|
445
|
+
if not name_match:
|
|
446
|
+
return None
|
|
447
|
+
name = name_match.group(1).strip(" ,.:;!?")
|
|
448
|
+
if name in ("a", "an", "the", "package", "packages", "library", "librarys", "tool"):
|
|
449
|
+
nxt = re.search(r"^\s*[a-z0-9]+", rest[name_match.end():])
|
|
450
|
+
if nxt:
|
|
451
|
+
name = nxt.group(0)
|
|
452
|
+
if not name or not re.search(r"[a-z0-9]", name):
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
# "latest" version request: "install latest opencv" — the spec's own
|
|
456
|
+
# example. The word was stripped above; if it was present, name now
|
|
457
|
+
# holds the real package and version stays "latest".
|
|
458
|
+
version = "latest"
|
|
459
|
+
ver_match = re.search(r"\b([0-9][0-9a-zA-Z_.+-]*)\s*$", rest)
|
|
460
|
+
if ver_match and re.match(r"^[0-9]", name_match.group(1)):
|
|
461
|
+
pass # name started with a digit (e.g. "3d"), don't treat as version
|
|
462
|
+
|
|
463
|
+
target = None
|
|
464
|
+
if re.search(r"\blocal(ly)?\b|\bproject\b", rest):
|
|
465
|
+
target = "local"
|
|
466
|
+
|
|
467
|
+
return PackageRequest(name=name, version=version, manager=None,
|
|
468
|
+
manager_hint=manager_hint, target=target, raw=text)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ------------------------------------------------------------ detection --
|
|
472
|
+
def manager_available(manager):
|
|
473
|
+
"""True if `manager` can be invoked on this machine (binary on PATH,
|
|
474
|
+
or for pip: this interpreter is usable)."""
|
|
475
|
+
if manager not in MANAGERS:
|
|
476
|
+
return False
|
|
477
|
+
spec = MANAGERS[manager]
|
|
478
|
+
if spec["platforms"]:
|
|
479
|
+
if "win" in spec["platforms"] and not _WIN:
|
|
480
|
+
return False
|
|
481
|
+
if "linux" in spec["platforms"] and not _LINUX:
|
|
482
|
+
return False
|
|
483
|
+
if "darwin" in spec["platforms"] and not _MAC:
|
|
484
|
+
return False
|
|
485
|
+
if manager == "pip":
|
|
486
|
+
try:
|
|
487
|
+
import importlib.util
|
|
488
|
+
return importlib.util.find_spec("pip") is not None
|
|
489
|
+
except Exception:
|
|
490
|
+
return False
|
|
491
|
+
return shutil.which(_binary_for(manager)) is not None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _binary_for(manager):
|
|
495
|
+
return {"chocolatey": "choco", "apt": "apt-get", "dnf": "dnf",
|
|
496
|
+
"brew": "brew", "pipx": "pipx", "uv": "uv", "conda": "conda",
|
|
497
|
+
"npm": "npm", "pnpm": "pnpm", "yarn": "yarn", "bun": "bun",
|
|
498
|
+
"cargo": "cargo", "go": "go", "composer": "composer",
|
|
499
|
+
"vcpkg": "vcpkg", "winget": "winget"}.get(manager, manager)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _native_system_manager():
|
|
503
|
+
"""The best system-package manager for this OS, or None."""
|
|
504
|
+
order = ["winget", "chocolatey", "brew", "apt", "dnf"]
|
|
505
|
+
for m in order:
|
|
506
|
+
if m in MANAGERS and manager_available(m):
|
|
507
|
+
return m
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def detect_manager(req):
|
|
512
|
+
"""Pick the manager for a PackageRequest using known-package tables,
|
|
513
|
+
the explicit hint, and the platform's system manager. Returns the
|
|
514
|
+
manager key, or None if the package is unknown/ambiguous."""
|
|
515
|
+
if req.manager:
|
|
516
|
+
return req.manager
|
|
517
|
+
if req.manager_hint:
|
|
518
|
+
return req.manager_hint if req.manager_hint in MANAGERS else None
|
|
519
|
+
|
|
520
|
+
name = req.name.lower()
|
|
521
|
+
# Scoped npm packages ("@scope/name") are unambiguous.
|
|
522
|
+
if name.startswith("@"):
|
|
523
|
+
return "npm"
|
|
524
|
+
# Standalone system tools (NodeJS, Rust, Docker, CUDA...).
|
|
525
|
+
for key, (cands, _binary) in SYSTEM_TOOLS.items():
|
|
526
|
+
if name == key or name.replace("-", " ") == key:
|
|
527
|
+
for c in cands:
|
|
528
|
+
if c == "rustup":
|
|
529
|
+
if shutil.which("rustup"):
|
|
530
|
+
return "rustup"
|
|
531
|
+
continue
|
|
532
|
+
if c in MANAGERS and manager_available(c):
|
|
533
|
+
return c
|
|
534
|
+
sys_mgr = _native_system_manager()
|
|
535
|
+
if sys_mgr:
|
|
536
|
+
return sys_mgr
|
|
537
|
+
return "winget" if _WIN else ("brew" if _MAC else "apt")
|
|
538
|
+
# Known ecosystems.
|
|
539
|
+
if name in PYTHON_PACKAGES:
|
|
540
|
+
return "pip"
|
|
541
|
+
if name in NODE_PACKAGES:
|
|
542
|
+
return "npm"
|
|
543
|
+
if name in RUST_CRATES:
|
|
544
|
+
return "cargo"
|
|
545
|
+
if name in GO_MODULES:
|
|
546
|
+
return "go"
|
|
547
|
+
if name in PHP_PACKAGES:
|
|
548
|
+
return "composer"
|
|
549
|
+
if name in _VCPKG_LIBS and _WIN:
|
|
550
|
+
return "vcpkg"
|
|
551
|
+
# Extension-based hints.
|
|
552
|
+
if name.startswith("lib") or name.endswith("-dev"):
|
|
553
|
+
return "apt" if _LINUX else None
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def resolve_request(text, prefer=None):
|
|
558
|
+
"""One-stop parse + detect. Returns (request, manager_key_or_None).
|
|
559
|
+
`prefer` is a manager the caller wants to try first (e.g. from a
|
|
560
|
+
research recommendation). Falls back to treating a bare package
|
|
561
|
+
name as "install <name>" (the /install flow passes bare names)."""
|
|
562
|
+
req = parse_request(text)
|
|
563
|
+
if req is None:
|
|
564
|
+
req = parse_request("install " + text)
|
|
565
|
+
if req is None:
|
|
566
|
+
return None, None
|
|
567
|
+
if prefer and prefer in MANAGERS:
|
|
568
|
+
req.manager = prefer
|
|
569
|
+
return req, prefer
|
|
570
|
+
return req, detect_manager(req)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ------------------------------------------------------------ execution --
|
|
574
|
+
_INSTALL_TIMEOUT = 900 # 15 minutes wall clock — big SDKs take a while
|
|
575
|
+
|
|
576
|
+
_RUNNABLE_EXTS = (".exe", ".bat", ".cmd", ".sh") if _WIN else ()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def run_install(req, on_event=None, cwd=None, timeout=_INSTALL_TIMEOUT,
|
|
580
|
+
capture=True):
|
|
581
|
+
"""Runs the real installer for `req` (req.manager must be set).
|
|
582
|
+
Streams Installation-Dashboard events through `on_event` and returns
|
|
583
|
+
a summary dict:
|
|
584
|
+
{"ok": bool, "manager": str, "command": [argv...],
|
|
585
|
+
"exit_code": int, "timed_out": bool, "output_tail": str,
|
|
586
|
+
"duration": float, "verify": {..} or None,
|
|
587
|
+
"deps_installed": int, "conflicts": bool}
|
|
588
|
+
Never raises for install failures — a failed install is a result,
|
|
589
|
+
not an exception. Also records the outcome in the package history
|
|
590
|
+
(v0.7.7 spec section 8)."""
|
|
591
|
+
result = _run_install_impl(req, on_event=on_event, cwd=cwd,
|
|
592
|
+
timeout=timeout, capture=capture)
|
|
593
|
+
try:
|
|
594
|
+
record_install(result, req)
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
return result
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _run_install_impl(req, on_event=None, cwd=None, timeout=_INSTALL_TIMEOUT,
|
|
601
|
+
capture=True):
|
|
602
|
+
from . import sound
|
|
603
|
+
manager = req.manager
|
|
604
|
+
if manager not in MANAGERS:
|
|
605
|
+
if on_event:
|
|
606
|
+
on_event({"stage": STAGE_FAILED,
|
|
607
|
+
"message": f"Unknown package manager '{manager}'."})
|
|
608
|
+
return {"ok": False, "manager": manager, "command": [],
|
|
609
|
+
"exit_code": -1, "timed_out": False, "output_tail": "",
|
|
610
|
+
"duration": 0.0, "verify": None,
|
|
611
|
+
"deps_installed": 0, "conflicts": False}
|
|
612
|
+
spec = MANAGERS[manager]
|
|
613
|
+
command = spec["install"](req.name, req.version, req.target)
|
|
614
|
+
if on_event:
|
|
615
|
+
on_event({"stage": STAGE_DOWNLOAD,
|
|
616
|
+
"message": f"Installing {req.label()} via {spec['label']}",
|
|
617
|
+
"detail": " ".join(command)})
|
|
618
|
+
t0 = time.time()
|
|
619
|
+
output_lines = []
|
|
620
|
+
try:
|
|
621
|
+
proc = subprocess.Popen(
|
|
622
|
+
command, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
623
|
+
text=True, encoding="utf-8", errors="replace",
|
|
624
|
+
env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
625
|
+
assert proc.stdout is not None
|
|
626
|
+
for line in proc.stdout:
|
|
627
|
+
line = line.rstrip("\n")
|
|
628
|
+
output_lines.append(line)
|
|
629
|
+
if on_event:
|
|
630
|
+
on_event({"stage": STAGE_RUNNING, "message": line})
|
|
631
|
+
proc.wait(timeout=timeout)
|
|
632
|
+
except subprocess.TimeoutExpired:
|
|
633
|
+
try:
|
|
634
|
+
proc.kill()
|
|
635
|
+
except Exception:
|
|
636
|
+
pass
|
|
637
|
+
duration = round(time.time() - t0, 2)
|
|
638
|
+
tail = "\n".join(output_lines[-20:])
|
|
639
|
+
if on_event:
|
|
640
|
+
on_event({"stage": STAGE_FAILED,
|
|
641
|
+
"message": f"Install timed out after {timeout}s."})
|
|
642
|
+
sound.play("error")
|
|
643
|
+
return {"ok": False, "manager": manager, "command": command,
|
|
644
|
+
"exit_code": 124, "timed_out": True, "output_tail": tail,
|
|
645
|
+
"duration": duration, "verify": None,
|
|
646
|
+
"deps_installed": 0, "conflicts": False}
|
|
647
|
+
except OSError as e:
|
|
648
|
+
duration = round(time.time() - t0, 2)
|
|
649
|
+
if on_event:
|
|
650
|
+
on_event({"stage": STAGE_FAILED,
|
|
651
|
+
"message": f"Could not run {' '.join(command)}: {e}"})
|
|
652
|
+
sound.play("error")
|
|
653
|
+
return {"ok": False, "manager": manager, "command": command,
|
|
654
|
+
"exit_code": -1, "timed_out": False,
|
|
655
|
+
"output_tail": f"Could not run: {e}", "duration": duration,
|
|
656
|
+
"verify": None, "deps_installed": 0, "conflicts": False}
|
|
657
|
+
duration = round(time.time() - t0, 2)
|
|
658
|
+
ok = proc.returncode == 0
|
|
659
|
+
tail = "\n".join(output_lines[-20:])
|
|
660
|
+
deps_count, conflicts = _analyze_output(output_lines, requested=req.name)
|
|
661
|
+
if ok:
|
|
662
|
+
if on_event:
|
|
663
|
+
on_event({"stage": STAGE_VERIFYING,
|
|
664
|
+
"message": f"{manager} reported success — verifying..."})
|
|
665
|
+
verify = verify_package(manager, req.name, req.version)
|
|
666
|
+
if verify and verify.get("ok"):
|
|
667
|
+
if on_event:
|
|
668
|
+
on_event({"stage": STAGE_DONE,
|
|
669
|
+
"message": f"Installed {req.label()} \u2014 verified "
|
|
670
|
+
f"{verify.get('verified', '')}"})
|
|
671
|
+
sound.play("success")
|
|
672
|
+
else:
|
|
673
|
+
note = (verify or {}).get("note") or "verification inconclusive"
|
|
674
|
+
if on_event:
|
|
675
|
+
on_event({"stage": STAGE_DONE,
|
|
676
|
+
"message": f"Install command finished for {req.label()} "
|
|
677
|
+
f"({note}) \u2014 treat as installed."})
|
|
678
|
+
sound.play("success")
|
|
679
|
+
return {"ok": True, "manager": manager, "command": command,
|
|
680
|
+
"exit_code": 0, "timed_out": False, "output_tail": tail,
|
|
681
|
+
"duration": duration, "verify": verify,
|
|
682
|
+
"deps_installed": deps_count, "conflicts": conflicts}
|
|
683
|
+
if on_event:
|
|
684
|
+
on_event({"stage": STAGE_FAILED,
|
|
685
|
+
"message": f"Install failed (exit {proc.returncode})."})
|
|
686
|
+
sound.play("error")
|
|
687
|
+
return {"ok": False, "manager": manager, "command": command,
|
|
688
|
+
"exit_code": proc.returncode, "timed_out": False,
|
|
689
|
+
"output_tail": tail, "duration": duration, "verify": None,
|
|
690
|
+
"deps_installed": deps_count, "conflicts": conflicts}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _analyze_output(output_lines, requested=None):
|
|
694
|
+
"""Best-effort read of the installer's own output:
|
|
695
|
+
- `deps_installed`: count of dependencies pip/npm report
|
|
696
|
+
installing (total minus the requested package itself);
|
|
697
|
+
- `conflicts`: whether the output mentions dependency conflicts.
|
|
698
|
+
Both honest heuristics over real output — 0/False when nothing is
|
|
699
|
+
reported (never fabricated)."""
|
|
700
|
+
text = "\n".join(output_lines)
|
|
701
|
+
deps = 0
|
|
702
|
+
for line in output_lines:
|
|
703
|
+
low = line.strip().lower()
|
|
704
|
+
if low.startswith("successfully installed "):
|
|
705
|
+
parts = line.split("Successfully installed ", 1)[1].split()
|
|
706
|
+
names = [p for p in parts if p and p not in ("and",)]
|
|
707
|
+
if requested:
|
|
708
|
+
wanted = requested.lower().replace("_", "-")
|
|
709
|
+
names = [n for n in names if n.split("==")[0].lower() != wanted]
|
|
710
|
+
deps = max(deps, len(names))
|
|
711
|
+
elif "added " in low and " packages" in low:
|
|
712
|
+
import re
|
|
713
|
+
m = re.search(r"added (\d+) packages", low)
|
|
714
|
+
if m:
|
|
715
|
+
deps = max(deps, max(int(m.group(1)) - 1, 0))
|
|
716
|
+
conflicts = "conflict" in text.lower()
|
|
717
|
+
return deps, conflicts
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def verify_package(manager, name, version="latest"):
|
|
721
|
+
"""Post-install verification (spec section 12): the manager's own
|
|
722
|
+
list/show query, plus (for pip-family managers) a real import test.
|
|
723
|
+
Returns {"ok": bool, "verified": str, "note": str} — never raises."""
|
|
724
|
+
if manager not in MANAGERS:
|
|
725
|
+
return {"ok": False, "verified": "", "note": f"unknown manager {manager}"}
|
|
726
|
+
spec = MANAGERS[manager]
|
|
727
|
+
try:
|
|
728
|
+
cmd = spec["verify"](name, version)
|
|
729
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60,
|
|
730
|
+
encoding="utf-8", errors="replace")
|
|
731
|
+
listed = result.returncode == 0 and bool((result.stdout or "").strip())
|
|
732
|
+
except Exception as e:
|
|
733
|
+
return {"ok": False, "verified": "", "note": f"verify command failed: {e}"}
|
|
734
|
+
|
|
735
|
+
# Import check for pip-family managers (spec section 12: "Import
|
|
736
|
+
# succeeds").
|
|
737
|
+
import_name = None
|
|
738
|
+
if spec.get("check_import"):
|
|
739
|
+
import_name = _IMPORT_ALIASES.get(name, name.replace("-", "_").lower())
|
|
740
|
+
try:
|
|
741
|
+
import importlib
|
|
742
|
+
importlib.import_module(import_name)
|
|
743
|
+
imported = True
|
|
744
|
+
except Exception:
|
|
745
|
+
imported = False
|
|
746
|
+
else:
|
|
747
|
+
imported = True
|
|
748
|
+
|
|
749
|
+
ok = listed and imported
|
|
750
|
+
pieces = []
|
|
751
|
+
if listed:
|
|
752
|
+
pieces.append(f"listed by {manager}")
|
|
753
|
+
if spec.get("check_import"):
|
|
754
|
+
pieces.append(f"import {import_name} "
|
|
755
|
+
+ ("succeeded" if imported else "failed"))
|
|
756
|
+
if not listed:
|
|
757
|
+
pieces.append(f"{manager} did not list it (may still be usable)")
|
|
758
|
+
return {"ok": ok, "verified": ", ".join(pieces), "note": " ".join(pieces)}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def importable_name(package_name):
|
|
762
|
+
return _IMPORT_ALIASES.get(package_name,
|
|
763
|
+
package_name.replace("-", "_").lower())
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ------------------------------------------------------------ permissions --
|
|
767
|
+
_DECISIONS_STORE = os.path.join(os.path.expanduser("~"), ".cct_package_decisions.json")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _load_decisions():
|
|
771
|
+
try:
|
|
772
|
+
with open(_DECISIONS_STORE, "r", encoding="utf-8") as f:
|
|
773
|
+
return json.load(f)
|
|
774
|
+
except Exception:
|
|
775
|
+
return {}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _save_decisions(data):
|
|
779
|
+
try:
|
|
780
|
+
with open(_DECISIONS_STORE, "w", encoding="utf-8") as f:
|
|
781
|
+
json.dump(data, f, indent=2)
|
|
782
|
+
return True
|
|
783
|
+
except Exception:
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def remembered_decision(manager, name):
|
|
788
|
+
"""'allow' / 'deny' / None — the persisted always-remembered choice
|
|
789
|
+
for this exact manager+package pair, if the user picked one in a
|
|
790
|
+
previous install flow (spec section 3: "Remember choice if
|
|
791
|
+
selected")."""
|
|
792
|
+
return _load_decisions().get(f"{manager}|{name.lower()}")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def remember_decision(manager, name, decision):
|
|
796
|
+
key = f"{manager}|{name.lower()}"
|
|
797
|
+
data = _load_decisions()
|
|
798
|
+
data[key] = decision
|
|
799
|
+
_save_decisions(data)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def forget_decision(manager, name):
|
|
803
|
+
data = _load_decisions()
|
|
804
|
+
data.pop(f"{manager}|{name.lower()}", None)
|
|
805
|
+
_save_decisions(data)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def list_remembered():
|
|
809
|
+
"""[(manager, name, decision), ...] for the /packages settings view."""
|
|
810
|
+
return [(k.split("|", 1)[0], k.split("|", 1)[1], v)
|
|
811
|
+
for k, v in _load_decisions().items() if "|" in k]
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# -------------------------------------------------------------- dashboard --
|
|
815
|
+
def dashboard_lines(events):
|
|
816
|
+
"""Renderer for the classic terminal: turns a list of events into
|
|
817
|
+
the Installation Dashboard lines (spec section 11) — package, manager,
|
|
818
|
+
command, live output, verification, completion status."""
|
|
819
|
+
from . import theme
|
|
820
|
+
lines = []
|
|
821
|
+
for ev in events:
|
|
822
|
+
stage = ev.get("stage")
|
|
823
|
+
msg = ev.get("message", "")
|
|
824
|
+
if stage == STAGE_RUNNING:
|
|
825
|
+
lines.append(theme.dim(" " + msg))
|
|
826
|
+
elif stage == STAGE_DOWNLOAD:
|
|
827
|
+
lines.append(theme.cyan(" \u2b07 ") + msg +
|
|
828
|
+
(theme.faint(" " + ev["detail"]) if ev.get("detail") else ""))
|
|
829
|
+
elif stage == STAGE_VERIFYING:
|
|
830
|
+
lines.append(theme.purple(" \U0001f50d ") + msg)
|
|
831
|
+
elif stage == STAGE_DONE:
|
|
832
|
+
lines.append(theme.green(" \u2713 ") + msg)
|
|
833
|
+
elif stage == STAGE_FAILED:
|
|
834
|
+
lines.append(theme.red(" \u2717 ") + msg)
|
|
835
|
+
elif stage == STAGE_PERMISSION:
|
|
836
|
+
lines.append(theme.orange(" \U0001f511 ") + msg)
|
|
837
|
+
elif stage == STAGE_SKIPPED:
|
|
838
|
+
lines.append(theme.faint(" \u2014 ") + msg)
|
|
839
|
+
else:
|
|
840
|
+
lines.append(theme.text(" " + msg))
|
|
841
|
+
return lines
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# ---------------------------------------------------------------- summary --
|
|
845
|
+
def _env_label():
|
|
846
|
+
"""Python version + platform for install summaries (spec section 7's
|
|
847
|
+
'Environment' row)."""
|
|
848
|
+
import platform
|
|
849
|
+
try:
|
|
850
|
+
return f"Python {platform.python_version()} \u00b7 {platform.system()}"
|
|
851
|
+
except Exception:
|
|
852
|
+
return "Python (unknown)"
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def summarize(result, req):
|
|
856
|
+
"""Professional install summary card (spec section 7) — the
|
|
857
|
+
classic REPL prints this in a panel after every install, and it
|
|
858
|
+
carries the spec's exact fields. Pure text — callers decide where
|
|
859
|
+
to render it."""
|
|
860
|
+
version = getattr(req, "version", None) or "latest"
|
|
861
|
+
if result.get("ok"):
|
|
862
|
+
verify = result.get("verify") or {}
|
|
863
|
+
lines = ["\u2705 Package Installed Successfully", ""]
|
|
864
|
+
if version != "latest":
|
|
865
|
+
lines.append(f"Package: {req.name} ({version})")
|
|
866
|
+
lines.append(f"Version: {version}")
|
|
867
|
+
else:
|
|
868
|
+
lines.append(f"Package: {req.name}")
|
|
869
|
+
lines.append(f"Environment: {_env_label()}")
|
|
870
|
+
lines.append(f"Dependencies Installed: {result.get('deps_installed', 0)}")
|
|
871
|
+
lines.append(f"Installation Time: {result.get('duration', 0)}s")
|
|
872
|
+
if verify.get("verified"):
|
|
873
|
+
lines.append(f"Verification: \U0001f50d {verify['verified']}")
|
|
874
|
+
elif verify.get("note"):
|
|
875
|
+
lines.append(f"Verification: {verify['note']}")
|
|
876
|
+
else:
|
|
877
|
+
lines.append("Verification: command reported success")
|
|
878
|
+
lines.append("")
|
|
879
|
+
if result.get("conflicts"):
|
|
880
|
+
lines.append("\u26a0 Dependency conflicts were mentioned in the install "
|
|
881
|
+
"output \u2014 review the dashboard log before use.")
|
|
882
|
+
else:
|
|
883
|
+
lines.append("No dependency conflicts detected.")
|
|
884
|
+
lines.append("Ready for use \u2014 ask CAT what to build next.")
|
|
885
|
+
return "\n".join(lines)
|
|
886
|
+
lines = ["\u274c Package Installation Failed", ""]
|
|
887
|
+
lines.append(f"Package: {req.name}")
|
|
888
|
+
lines.append(f"Manager: {MANAGERS[req.manager]['label']}")
|
|
889
|
+
lines.append(f"Exit code: {result.get('exit_code')}"
|
|
890
|
+
+ (" (timed out)" if result.get("timed_out") else ""))
|
|
891
|
+
tail = (result.get("output_tail") or "").strip()
|
|
892
|
+
if tail:
|
|
893
|
+
lines.append("")
|
|
894
|
+
lines.append("Logs (last lines):")
|
|
895
|
+
for line in tail.splitlines()[-6:]:
|
|
896
|
+
lines.append(" " + line)
|
|
897
|
+
lines.append("")
|
|
898
|
+
lines.append("Suggestion: check the package name/version and network, "
|
|
899
|
+
"then retry \u2014 or ask CAT to research alternatives.")
|
|
900
|
+
return "\n".join(lines)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
# ---------------------------------------------------------------- history --
|
|
904
|
+
# v0.7.7 spec section 8: persistent package installation history, kept
|
|
905
|
+
# alongside the remembered-decisions store in the same dotfile family.
|
|
906
|
+
HISTORY_PATH = os.path.join(os.path.expanduser("~"), ".cct_package_history.json")
|
|
907
|
+
MAX_HISTORY = 100
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _load_history():
|
|
911
|
+
if not os.path.exists(HISTORY_PATH):
|
|
912
|
+
return []
|
|
913
|
+
try:
|
|
914
|
+
with open(HISTORY_PATH, "r", encoding="utf-8") as f:
|
|
915
|
+
import json as _json
|
|
916
|
+
data = _json.load(f)
|
|
917
|
+
return data if isinstance(data, list) else []
|
|
918
|
+
except Exception:
|
|
919
|
+
return []
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _save_history(records):
|
|
923
|
+
try:
|
|
924
|
+
with open(HISTORY_PATH, "w", encoding="utf-8") as f:
|
|
925
|
+
import json as _json
|
|
926
|
+
_json.dump(records, f, indent=2)
|
|
927
|
+
return True
|
|
928
|
+
except Exception:
|
|
929
|
+
return False
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def record_install(result, req):
|
|
933
|
+
"""Records one install outcome (called automatically by
|
|
934
|
+
run_install). A reinstall of an already-recorded package updates
|
|
935
|
+
that record's `updated_at` (and version) instead of duplicating it
|
|
936
|
+
(spec section 8's Update semantics)."""
|
|
937
|
+
records = _load_history()
|
|
938
|
+
now = time.time()
|
|
939
|
+
name = getattr(req, "name", "") or ""
|
|
940
|
+
if not name:
|
|
941
|
+
return
|
|
942
|
+
existing = next((r for r in records if r.get("name") == name), None)
|
|
943
|
+
entry = {
|
|
944
|
+
"name": name,
|
|
945
|
+
"version": getattr(req, "version", "latest") or "latest",
|
|
946
|
+
"manager": getattr(req, "manager", "") or "",
|
|
947
|
+
"ok": bool(result.get("ok")),
|
|
948
|
+
"env": _env_label(),
|
|
949
|
+
"installed_at": now,
|
|
950
|
+
"updated_at": now,
|
|
951
|
+
"duration": result.get("duration", 0),
|
|
952
|
+
"verified": bool((result.get("verify") or {}).get("ok")),
|
|
953
|
+
"conflicts": bool(result.get("conflicts")),
|
|
954
|
+
"exit_code": result.get("exit_code"),
|
|
955
|
+
"error_tail": (result.get("output_tail") or "")[-400:] if not result.get("ok") else "",
|
|
956
|
+
"notes": "",
|
|
957
|
+
}
|
|
958
|
+
if existing is not None:
|
|
959
|
+
existing.update({k: v for k, v in entry.items() if k != "installed_at"})
|
|
960
|
+
existing["updated_at"] = now
|
|
961
|
+
existing["version"] = entry["version"]
|
|
962
|
+
else:
|
|
963
|
+
records.insert(0, entry)
|
|
964
|
+
records = records[:MAX_HISTORY]
|
|
965
|
+
_save_history(records)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def package_history(limit=50):
|
|
969
|
+
"""All install-history records, newest first."""
|
|
970
|
+
return _load_history()[:limit]
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def history_search(query):
|
|
974
|
+
"""Filter history by package name or manager (case-insensitive)."""
|
|
975
|
+
q = (query or "").strip().lower()
|
|
976
|
+
if not q:
|
|
977
|
+
return package_history()
|
|
978
|
+
return [r for r in _load_history()
|
|
979
|
+
if q in r.get("name", "").lower() or q in r.get("manager", "").lower()]
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def remove_history(name):
|
|
983
|
+
"""Removes every history record for `name`. Returns the number of
|
|
984
|
+
records removed. Only touches the history JSON — never the
|
|
985
|
+
installed package itself."""
|
|
986
|
+
records = _load_history()
|
|
987
|
+
kept = [r for r in records if r.get("name") != name]
|
|
988
|
+
removed = len(records) - len(kept)
|
|
989
|
+
if removed:
|
|
990
|
+
_save_history(kept)
|
|
991
|
+
return removed
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def history_entry(name):
|
|
995
|
+
"""The newest history record for `name`, or None."""
|
|
996
|
+
for r in _load_history():
|
|
997
|
+
if r.get("name") == name:
|
|
998
|
+
return r
|
|
999
|
+
return None
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def clear_history():
|
|
1003
|
+
"""Empties the whole install history. Only the JSON store — never
|
|
1004
|
+
uninstalls anything. Returns True when there was something to
|
|
1005
|
+
clear."""
|
|
1006
|
+
records = _load_history()
|
|
1007
|
+
if records:
|
|
1008
|
+
_save_history([])
|
|
1009
|
+
return True
|
|
1010
|
+
return False
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def announce(result, req):
|
|
1014
|
+
"""Short single-line status for the activity log / event bus."""
|
|
1015
|
+
from . import sound
|
|
1016
|
+
if result.get("ok"):
|
|
1017
|
+
sound.play("success")
|
|
1018
|
+
eventbus.bus.publish(eventbus.PACKAGE_INSTALLED,
|
|
1019
|
+
package=req.label(), manager=req.manager or "")
|
|
1020
|
+
return f"Installed {req.label()} via {req.manager}"
|
|
1021
|
+
sound.play("error")
|
|
1022
|
+
eventbus.bus.publish(eventbus.PACKAGE_FAILED,
|
|
1023
|
+
package=req.label(), manager=req.manager or "")
|
|
1024
|
+
return f"Failed to install {req.label()} via {req.manager}"
|