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,733 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CCT Extension Manager — VS Code-style (v0.7.9.10).
|
|
3
|
+
|
|
4
|
+
Central registry for Gestures / CAT Vision / Personalize and future
|
|
5
|
+
third-party extensions. Implements the full lifecycle:
|
|
6
|
+
|
|
7
|
+
NOT_INSTALLED -> INSTALL -> INSTALLED_ENABLED
|
|
8
|
+
INSTALLED_ENABLED -> DISABLE -> INSTALLED_DISABLED
|
|
9
|
+
INSTALLED_DISABLED -> ENABLE -> INSTALLED_ENABLED
|
|
10
|
+
INSTALLED_* -> UNINSTALL -> NOT_INSTALLED
|
|
11
|
+
|
|
12
|
+
Main menu queries is_installed() && is_enabled() — never hard-coded.
|
|
13
|
+
|
|
14
|
+
Persistence: ~/.cct_extensions_registry.json (source of truth) +
|
|
15
|
+
~/.cct_config.json .extensions for backward compat migration.
|
|
16
|
+
Startup: discover() -> validate -> load enabled -> register features.
|
|
17
|
+
|
|
18
|
+
Features: metadata (name, icon, creator, verified, trusted, size,
|
|
19
|
+
version, category), size from filesystem, publisher identity (individual
|
|
20
|
+
vs company), trusted, VS Code-style UX, error isolation.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import threading
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Dict, List, Optional, Any
|
|
31
|
+
|
|
32
|
+
# --------------------------------------------------------------------------
|
|
33
|
+
# Built-in extension definitions (available to install). These use the same
|
|
34
|
+
# infrastructure as future third-party extensions — no special hard-coded
|
|
35
|
+
# path for these three.
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
# Base directory for size calculation (calc_terminal package)
|
|
39
|
+
_PKG_ROOT = Path(__file__).parent
|
|
40
|
+
|
|
41
|
+
def _calc_size(paths: List[Path]) -> int:
|
|
42
|
+
"""Sum file sizes for given paths (files or dirs)."""
|
|
43
|
+
total = 0
|
|
44
|
+
for p in paths:
|
|
45
|
+
try:
|
|
46
|
+
if p.is_file():
|
|
47
|
+
total += p.stat().st_size
|
|
48
|
+
elif p.is_dir():
|
|
49
|
+
for f in p.rglob("*"):
|
|
50
|
+
if f.is_file():
|
|
51
|
+
try:
|
|
52
|
+
total += f.stat().st_size
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return total
|
|
58
|
+
|
|
59
|
+
def _format_size(nbytes: int) -> str:
|
|
60
|
+
if nbytes < 1024:
|
|
61
|
+
return f"{nbytes} B"
|
|
62
|
+
if nbytes < 1024 * 1024:
|
|
63
|
+
return f"{nbytes/1024:.1f} KB"
|
|
64
|
+
return f"{nbytes/1024/1024:.1f} MB"
|
|
65
|
+
|
|
66
|
+
# Built-in extensions — these are the only ones shipped with CAT.
|
|
67
|
+
# Future third-party extensions will be discovered from ~/.cct_extensions/
|
|
68
|
+
# or registry and use the same schema.
|
|
69
|
+
BUILTIN_EXTENSIONS: Dict[str, Dict[str, Any]] = {
|
|
70
|
+
"gestures": {
|
|
71
|
+
"id": "gestures",
|
|
72
|
+
"name": "Gestures",
|
|
73
|
+
"display_name": "Gestures",
|
|
74
|
+
"description": "Advanced gesture controls for CAT — double-click, long-press, swipe and custom shortcuts.",
|
|
75
|
+
"icon": "✋",
|
|
76
|
+
"publisher": "Kazi Zillani",
|
|
77
|
+
"publisher_type": "individual", # individual | company | organization
|
|
78
|
+
"publisher_verified": True,
|
|
79
|
+
"verified": True,
|
|
80
|
+
"trusted": True,
|
|
81
|
+
"version": "1.0.0",
|
|
82
|
+
"category": "Productivity",
|
|
83
|
+
"keywords": ["gestures", "shortcuts", "productivity", "touch"],
|
|
84
|
+
"size_bytes": None, # calculated lazily
|
|
85
|
+
"size_display": None,
|
|
86
|
+
"action": "gestures", # header.NAV_ITEMS action id
|
|
87
|
+
"kind": "builtin",
|
|
88
|
+
},
|
|
89
|
+
"personalize": {
|
|
90
|
+
"id": "personalize",
|
|
91
|
+
"name": "Personalize",
|
|
92
|
+
"display_name": "Personalize",
|
|
93
|
+
"description": "AI personality, tone, custom instructions and memory — make CAT yours.",
|
|
94
|
+
"icon": "🎨",
|
|
95
|
+
"publisher": "Kazi Zillani",
|
|
96
|
+
"publisher_type": "individual",
|
|
97
|
+
"publisher_verified": True,
|
|
98
|
+
"verified": True,
|
|
99
|
+
"trusted": True,
|
|
100
|
+
"version": "1.0.0",
|
|
101
|
+
"category": "Personalization",
|
|
102
|
+
"keywords": ["personalize", "personality", "tone", "instructions", "memory"],
|
|
103
|
+
"size_bytes": None,
|
|
104
|
+
"size_display": None,
|
|
105
|
+
"action": "customize_ai",
|
|
106
|
+
"kind": "builtin",
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# For size, map each builtin to its source files/dirs
|
|
111
|
+
_BUILTIN_PATHS = {
|
|
112
|
+
"gestures": [_PKG_ROOT / "gestures", _PKG_ROOT / "ui" / "gestures_panel.py"],
|
|
113
|
+
"personalize": [_PKG_ROOT / "ai_personalization.py", _PKG_ROOT / "ui" / "personalize_center.py"],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Backward compat alias (old code imported EXTENSIONS)
|
|
117
|
+
EXTENSIONS = BUILTIN_EXTENSIONS
|
|
118
|
+
|
|
119
|
+
# Registry file (source of truth)
|
|
120
|
+
REGISTRY_FILE = Path.home() / ".cct_extensions_registry.json"
|
|
121
|
+
# Directory for custom user extensions
|
|
122
|
+
CUSTOM_EXTENSIONS_DIR = Path.home() / ".cct_extensions"
|
|
123
|
+
# Legacy config key for migration
|
|
124
|
+
_LEGACY_CONFIG_KEY = "extensions"
|
|
125
|
+
|
|
126
|
+
_lock = threading.RLock()
|
|
127
|
+
|
|
128
|
+
# In-memory cache of registry state
|
|
129
|
+
# state: {ext_id: {"installed": bool, "enabled": bool, "version": str, "install_time": float, "size_bytes": int}}
|
|
130
|
+
_cached_state: Optional[Dict[str, Dict[str, Any]]] = None
|
|
131
|
+
_cached_mtime: float = 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_registry() -> Dict[str, Dict[str, Any]]:
|
|
135
|
+
global _cached_state, _cached_mtime
|
|
136
|
+
with _lock:
|
|
137
|
+
try:
|
|
138
|
+
mtime = REGISTRY_FILE.stat().st_mtime if REGISTRY_FILE.exists() else 0
|
|
139
|
+
if _cached_state is not None and mtime == _cached_mtime:
|
|
140
|
+
return dict(_cached_state)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# Try registry file
|
|
145
|
+
data: Dict[str, Dict[str, Any]] = {}
|
|
146
|
+
if REGISTRY_FILE.exists():
|
|
147
|
+
try:
|
|
148
|
+
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
|
|
149
|
+
raw = json.load(f)
|
|
150
|
+
if isinstance(raw, dict):
|
|
151
|
+
# support both {id: {installed, enabled}} and legacy list
|
|
152
|
+
if "extensions" in raw and isinstance(raw["extensions"], dict):
|
|
153
|
+
data = raw["extensions"]
|
|
154
|
+
else:
|
|
155
|
+
# flat dict
|
|
156
|
+
data = raw
|
|
157
|
+
# filter to known structure
|
|
158
|
+
filtered = {}
|
|
159
|
+
for k, v in data.items():
|
|
160
|
+
if isinstance(v, dict):
|
|
161
|
+
filtered[str(k)] = {
|
|
162
|
+
"installed": bool(v.get("installed", False)),
|
|
163
|
+
"enabled": bool(v.get("enabled", False)),
|
|
164
|
+
"version": str(v.get("version", "")),
|
|
165
|
+
"install_time": float(v.get("install_time", 0) or 0),
|
|
166
|
+
"size_bytes": int(v.get("size_bytes", 0) or 0),
|
|
167
|
+
}
|
|
168
|
+
elif isinstance(v, bool):
|
|
169
|
+
# legacy: True => installed+enabled, False => installed disabled? But old code used False = disabled (installed but disabled)
|
|
170
|
+
# For migration, treat True as installed+enabled, False as installed+disabled
|
|
171
|
+
filtered[str(k)] = {
|
|
172
|
+
"installed": True,
|
|
173
|
+
"enabled": bool(v),
|
|
174
|
+
"version": "",
|
|
175
|
+
"install_time": 0,
|
|
176
|
+
"size_bytes": 0,
|
|
177
|
+
}
|
|
178
|
+
data = filtered
|
|
179
|
+
except Exception:
|
|
180
|
+
data = {}
|
|
181
|
+
|
|
182
|
+
# Migration from legacy config.extensions (simple bool dict)
|
|
183
|
+
if not data:
|
|
184
|
+
try:
|
|
185
|
+
from . import config as _cfg
|
|
186
|
+
legacy = getattr(_cfg.load_config(), "extensions", None) or {}
|
|
187
|
+
if isinstance(legacy, dict) and legacy:
|
|
188
|
+
for k, v in legacy.items():
|
|
189
|
+
if k in BUILTIN_EXTENSIONS:
|
|
190
|
+
if isinstance(v, bool):
|
|
191
|
+
data[str(k)] = {
|
|
192
|
+
"installed": True,
|
|
193
|
+
"enabled": bool(v),
|
|
194
|
+
"version": BUILTIN_EXTENSIONS[k].get("version", "1.0.0"),
|
|
195
|
+
"install_time": time.time(),
|
|
196
|
+
"size_bytes": _get_size_for(k),
|
|
197
|
+
}
|
|
198
|
+
elif isinstance(v, dict):
|
|
199
|
+
data[str(k)] = {
|
|
200
|
+
"installed": bool(v.get("installed", True)),
|
|
201
|
+
"enabled": bool(v.get("enabled", True)),
|
|
202
|
+
"version": str(v.get("version", "")),
|
|
203
|
+
"install_time": float(v.get("install_time", 0) or time.time()),
|
|
204
|
+
"size_bytes": int(v.get("size_bytes", 0) or _get_size_for(k)),
|
|
205
|
+
}
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# First run: extensions are NOT installed until user installs them
|
|
210
|
+
# (VS Code-style: Install → Enable → appears in menu).
|
|
211
|
+
# Keep data empty so get_state() returns NOT_INSTALLED for builtins.
|
|
212
|
+
if not data:
|
|
213
|
+
# don't auto-install — user must click Install in Extensions panel
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
_cached_state = dict(data)
|
|
217
|
+
try:
|
|
218
|
+
_cached_mtime = REGISTRY_FILE.stat().st_mtime if REGISTRY_FILE.exists() else time.time()
|
|
219
|
+
except Exception:
|
|
220
|
+
_cached_mtime = time.time()
|
|
221
|
+
return dict(data)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _save_registry(data: Dict[str, Dict[str, Any]]):
|
|
225
|
+
global _cached_state, _cached_mtime
|
|
226
|
+
with _lock:
|
|
227
|
+
try:
|
|
228
|
+
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
payload = {
|
|
230
|
+
"version": 1,
|
|
231
|
+
"updated": time.time(),
|
|
232
|
+
"extensions": data,
|
|
233
|
+
}
|
|
234
|
+
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
|
|
235
|
+
json.dump(payload, f, indent=2)
|
|
236
|
+
_cached_state = dict(data)
|
|
237
|
+
try:
|
|
238
|
+
_cached_mtime = REGISTRY_FILE.stat().st_mtime
|
|
239
|
+
except Exception:
|
|
240
|
+
_cached_mtime = time.time()
|
|
241
|
+
# also sync to config.extensions for backward compat (simple bool)
|
|
242
|
+
try:
|
|
243
|
+
from . import config as _cfg
|
|
244
|
+
cfg = _cfg.load_config()
|
|
245
|
+
# keep legacy simple dict for old code that reads config.extensions
|
|
246
|
+
legacy = {}
|
|
247
|
+
for k, v in data.items():
|
|
248
|
+
# only store enabled if installed, else not present? For legacy, store enabled bool if installed, else missing
|
|
249
|
+
if v.get("installed"):
|
|
250
|
+
legacy[k] = bool(v.get("enabled", False))
|
|
251
|
+
cfg.extensions = legacy
|
|
252
|
+
_cfg.save_config(cfg)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _get_size_for(ext_id: str) -> int:
|
|
260
|
+
"""Calculate size for extension from its source files."""
|
|
261
|
+
# Check cache from builtin
|
|
262
|
+
meta = BUILTIN_EXTENSIONS.get(ext_id)
|
|
263
|
+
if meta and meta.get("size_bytes"):
|
|
264
|
+
return int(meta["size_bytes"])
|
|
265
|
+
paths = _BUILTIN_PATHS.get(ext_id, [])
|
|
266
|
+
nbytes = _calc_size(paths) if paths else 0
|
|
267
|
+
# if no paths, try to find extension dir ~/.cct_extensions/<id>/
|
|
268
|
+
if nbytes == 0:
|
|
269
|
+
ext_dir = Path.home() / ".cct_extensions" / ext_id
|
|
270
|
+
if ext_dir.exists():
|
|
271
|
+
nbytes = _calc_size([ext_dir])
|
|
272
|
+
if nbytes == 0:
|
|
273
|
+
# fallback: estimate 100KB per extension
|
|
274
|
+
nbytes = 1024 * 100
|
|
275
|
+
return nbytes
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _ensure_size(ext_id: str, state: Dict[str, Any]) -> int:
|
|
279
|
+
if state.get("size_bytes"):
|
|
280
|
+
return int(state["size_bytes"])
|
|
281
|
+
nbytes = _get_size_for(ext_id)
|
|
282
|
+
state["size_bytes"] = nbytes
|
|
283
|
+
return nbytes
|
|
284
|
+
|
|
285
|
+
# --------------------------------------------------------------------------
|
|
286
|
+
# Public ExtensionManager API (spec section 15)
|
|
287
|
+
# --------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def discover() -> List[Dict[str, Any]]:
|
|
290
|
+
"""Discover available extensions (built-in + installed third-party)."""
|
|
291
|
+
return list_extensions()
|
|
292
|
+
|
|
293
|
+
def is_installed(extension_id: str) -> bool:
|
|
294
|
+
data = _load_registry()
|
|
295
|
+
rec = data.get(extension_id)
|
|
296
|
+
return bool(rec and rec.get("installed"))
|
|
297
|
+
|
|
298
|
+
def is_enabled(extension_id: str) -> bool:
|
|
299
|
+
data = _load_registry()
|
|
300
|
+
rec = data.get(extension_id)
|
|
301
|
+
return bool(rec and rec.get("installed") and rec.get("enabled"))
|
|
302
|
+
|
|
303
|
+
def is_disabled(extension_id: str) -> bool:
|
|
304
|
+
data = _load_registry()
|
|
305
|
+
rec = data.get(extension_id)
|
|
306
|
+
return bool(rec and rec.get("installed") and not rec.get("enabled"))
|
|
307
|
+
|
|
308
|
+
def get_state(extension_id: str) -> str:
|
|
309
|
+
"""Return state string: NOT_INSTALLED | INSTALLED_DISABLED | INSTALLED_ENABLED | INSTALLING etc."""
|
|
310
|
+
data = _load_registry()
|
|
311
|
+
rec = data.get(extension_id)
|
|
312
|
+
if not rec or not rec.get("installed"):
|
|
313
|
+
return "NOT_INSTALLED"
|
|
314
|
+
if rec.get("enabled"):
|
|
315
|
+
return "INSTALLED_ENABLED"
|
|
316
|
+
return "INSTALLED_DISABLED"
|
|
317
|
+
|
|
318
|
+
def get_metadata(extension_id: str) -> Optional[Dict[str, Any]]:
|
|
319
|
+
"""Return full metadata for extension, including live size, installed/enabled."""
|
|
320
|
+
base = BUILTIN_EXTENSIONS.get(extension_id)
|
|
321
|
+
if not base:
|
|
322
|
+
# Check custom extension directory ~/.cct_extensions/<id>/
|
|
323
|
+
ext_dir = CUSTOM_EXTENSIONS_DIR / extension_id
|
|
324
|
+
manifest_file = ext_dir / "extension.json"
|
|
325
|
+
data = _load_registry()
|
|
326
|
+
rec = data.get(extension_id, {})
|
|
327
|
+
manifest = {}
|
|
328
|
+
if manifest_file.exists():
|
|
329
|
+
try:
|
|
330
|
+
with open(manifest_file, "r", encoding="utf-8") as f:
|
|
331
|
+
manifest = json.load(f)
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
if not manifest and rec and isinstance(rec.get("manifest"), dict):
|
|
335
|
+
manifest = rec["manifest"]
|
|
336
|
+
|
|
337
|
+
if manifest or rec:
|
|
338
|
+
nbytes = _calc_size([ext_dir]) if ext_dir.exists() else int(rec.get("size_bytes", 1024 * 100))
|
|
339
|
+
display_name = manifest.get("name") or manifest.get("display_name") or extension_id.title()
|
|
340
|
+
return {
|
|
341
|
+
"id": extension_id,
|
|
342
|
+
"name": display_name,
|
|
343
|
+
"display_name": display_name,
|
|
344
|
+
"description": manifest.get("description", "Custom user extension"),
|
|
345
|
+
"icon": manifest.get("icon", "🧩"),
|
|
346
|
+
"publisher": manifest.get("publisher", rec.get("publisher", "Unknown")),
|
|
347
|
+
"publisher_type": manifest.get("publisher_type", "individual"),
|
|
348
|
+
"publisher_verified": False,
|
|
349
|
+
"verified": False,
|
|
350
|
+
"trusted": bool(manifest.get("trusted", rec.get("trusted", False))),
|
|
351
|
+
"version": manifest.get("version", rec.get("version", "1.0.0")),
|
|
352
|
+
"category": manifest.get("category", "Custom"),
|
|
353
|
+
"keywords": manifest.get("keywords", ["custom", extension_id]),
|
|
354
|
+
"size_bytes": nbytes,
|
|
355
|
+
"size_display": _format_size(nbytes),
|
|
356
|
+
"installed": bool(rec.get("installed", True)),
|
|
357
|
+
"enabled": bool(rec.get("enabled", True)),
|
|
358
|
+
"install_time": rec.get("install_time", 0),
|
|
359
|
+
"state": get_state(extension_id),
|
|
360
|
+
"kind": "custom",
|
|
361
|
+
"action": manifest.get("action", f"ext_{extension_id}"),
|
|
362
|
+
}
|
|
363
|
+
return None
|
|
364
|
+
# built-in
|
|
365
|
+
data = _load_registry()
|
|
366
|
+
rec = data.get(extension_id, {})
|
|
367
|
+
is_inst = bool(rec.get("installed")) if rec else True # default installed for backward compat handled in _load_registry
|
|
368
|
+
# Actually _load_registry ensures installed for builtins if empty, so if rec is None after load, it means not installed? But _load_registry would have created it if empty.
|
|
369
|
+
# For explicit check, if rec is None, treat as not installed only if registry has been initialized with data
|
|
370
|
+
# To know if not installed, check if extension_id in data
|
|
371
|
+
installed = is_installed(extension_id)
|
|
372
|
+
enabled = is_enabled(extension_id)
|
|
373
|
+
# calculate size
|
|
374
|
+
nbytes = 0
|
|
375
|
+
if rec:
|
|
376
|
+
nbytes = rec.get("size_bytes") or _get_size_for(extension_id)
|
|
377
|
+
else:
|
|
378
|
+
nbytes = _get_size_for(extension_id)
|
|
379
|
+
meta = dict(base)
|
|
380
|
+
meta.update({
|
|
381
|
+
"size_bytes": nbytes,
|
|
382
|
+
"size_display": _format_size(nbytes),
|
|
383
|
+
"installed": installed,
|
|
384
|
+
"enabled": enabled,
|
|
385
|
+
"state": get_state(extension_id),
|
|
386
|
+
"install_time": rec.get("install_time", 0) if rec else 0,
|
|
387
|
+
})
|
|
388
|
+
return meta
|
|
389
|
+
|
|
390
|
+
def get_size(extension_id: str) -> int:
|
|
391
|
+
meta = get_metadata(extension_id)
|
|
392
|
+
return int(meta["size_bytes"]) if meta else 0
|
|
393
|
+
|
|
394
|
+
def list_extensions() -> List[Dict[str, Any]]:
|
|
395
|
+
"""List all available extensions (built-in + discovered) with live states."""
|
|
396
|
+
out = []
|
|
397
|
+
seen = set()
|
|
398
|
+
# built-ins first
|
|
399
|
+
for kid in BUILTIN_EXTENSIONS:
|
|
400
|
+
meta = get_metadata(kid)
|
|
401
|
+
if meta:
|
|
402
|
+
out.append(meta)
|
|
403
|
+
seen.add(kid)
|
|
404
|
+
# discover custom extensions from ~/.cct_extensions
|
|
405
|
+
if CUSTOM_EXTENSIONS_DIR.exists():
|
|
406
|
+
try:
|
|
407
|
+
for sub in CUSTOM_EXTENSIONS_DIR.iterdir():
|
|
408
|
+
if sub.is_dir() and sub.name not in seen:
|
|
409
|
+
meta = get_metadata(sub.name)
|
|
410
|
+
if meta:
|
|
411
|
+
out.append(meta)
|
|
412
|
+
seen.add(sub.name)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
# add any custom installed extensions in registry not in builtins
|
|
416
|
+
data = _load_registry()
|
|
417
|
+
for kid in data:
|
|
418
|
+
if kid not in seen:
|
|
419
|
+
meta = get_metadata(kid)
|
|
420
|
+
if meta:
|
|
421
|
+
out.append(meta)
|
|
422
|
+
seen.add(kid)
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
def list_installed() -> List[Dict[str, Any]]:
|
|
426
|
+
return [m for m in list_extensions() if m.get("installed")]
|
|
427
|
+
|
|
428
|
+
def list_enabled() -> List[Dict[str, Any]]:
|
|
429
|
+
return [m for m in list_extensions() if m.get("enabled") and m.get("installed")]
|
|
430
|
+
|
|
431
|
+
# Lifecycle: INSTALL -> INSTALLED_ENABLED
|
|
432
|
+
def install(extension_id: str) -> tuple[bool, str]:
|
|
433
|
+
"""INSTALL: NOT_INSTALLED -> INSTALLED_ENABLED. Returns (ok, msg)."""
|
|
434
|
+
# validate exists as available
|
|
435
|
+
if extension_id not in BUILTIN_EXTENSIONS:
|
|
436
|
+
# check if it's already installed custom
|
|
437
|
+
data = _load_registry()
|
|
438
|
+
if extension_id in data and data[extension_id].get("installed"):
|
|
439
|
+
return False, f"{extension_id} is already installed"
|
|
440
|
+
# unknown extension — cannot install from marketplace yet (would need download)
|
|
441
|
+
# For built-in, we allow install; for unknown, treat as install of custom placeholder
|
|
442
|
+
if extension_id not in data:
|
|
443
|
+
return False, f"Extension '{extension_id}' not found in marketplace"
|
|
444
|
+
data = _load_registry()
|
|
445
|
+
rec = data.get(extension_id)
|
|
446
|
+
if rec and rec.get("installed"):
|
|
447
|
+
# if already installed but disabled, enable it (spec: INSTALL on installed should probably enable)
|
|
448
|
+
if not rec.get("enabled"):
|
|
449
|
+
rec["enabled"] = True
|
|
450
|
+
_save_registry(data)
|
|
451
|
+
return True, f"{extension_id} enabled"
|
|
452
|
+
return False, f"{extension_id} is already installed"
|
|
453
|
+
# perform install
|
|
454
|
+
nbytes = _get_size_for(extension_id)
|
|
455
|
+
# simulate installing state (could be async, but spec says update immediately)
|
|
456
|
+
data[extension_id] = {
|
|
457
|
+
"installed": True,
|
|
458
|
+
"enabled": True,
|
|
459
|
+
"version": BUILTIN_EXTENSIONS.get(extension_id, {}).get("version", "1.0.0"),
|
|
460
|
+
"install_time": time.time(),
|
|
461
|
+
"size_bytes": nbytes,
|
|
462
|
+
}
|
|
463
|
+
_save_registry(data)
|
|
464
|
+
# CAT Customization lifecycle hook
|
|
465
|
+
if extension_id == "customization":
|
|
466
|
+
try:
|
|
467
|
+
from . import customization as _cust
|
|
468
|
+
_cust.on_install()
|
|
469
|
+
_cust.on_enable()
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
# feature registration will happen via should_show_in_menu / startup
|
|
473
|
+
return True, f"✓ {BUILTIN_EXTENSIONS.get(extension_id, {}).get('display_name', extension_id)} installed successfully"
|
|
474
|
+
|
|
475
|
+
def uninstall(extension_id: str) -> tuple[bool, str]:
|
|
476
|
+
"""UNINSTALL: INSTALLED_* -> NOT_INSTALLED. Returns (ok, msg)."""
|
|
477
|
+
data = _load_registry()
|
|
478
|
+
rec = data.get(extension_id)
|
|
479
|
+
if not rec or not rec.get("installed"):
|
|
480
|
+
return False, f"{extension_id} is not installed"
|
|
481
|
+
# remove
|
|
482
|
+
del data[extension_id]
|
|
483
|
+
_save_registry(data)
|
|
484
|
+
# also clean up extension directory if exists
|
|
485
|
+
try:
|
|
486
|
+
ext_dir = Path.home() / ".cct_extensions" / extension_id
|
|
487
|
+
if ext_dir.exists():
|
|
488
|
+
import shutil
|
|
489
|
+
shutil.rmtree(ext_dir, ignore_errors=True)
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
if extension_id == "customization":
|
|
493
|
+
try:
|
|
494
|
+
from . import customization as _cust2
|
|
495
|
+
_cust2.on_uninstall()
|
|
496
|
+
except Exception:
|
|
497
|
+
pass
|
|
498
|
+
return True, f"✓ {extension_id} uninstalled"
|
|
499
|
+
|
|
500
|
+
def enable(extension_id: str) -> tuple[bool, str]:
|
|
501
|
+
"""ENABLE: INSTALLED_DISABLED -> INSTALLED_ENABLED."""
|
|
502
|
+
data = _load_registry()
|
|
503
|
+
rec = data.get(extension_id)
|
|
504
|
+
if not rec or not rec.get("installed"):
|
|
505
|
+
return False, f"{extension_id} is not installed — install first"
|
|
506
|
+
if rec.get("enabled"):
|
|
507
|
+
return False, f"{extension_id} is already enabled"
|
|
508
|
+
rec["enabled"] = True
|
|
509
|
+
_save_registry(data)
|
|
510
|
+
if extension_id == "customization":
|
|
511
|
+
try:
|
|
512
|
+
from . import customization as _cust3
|
|
513
|
+
_cust3.on_enable()
|
|
514
|
+
except Exception:
|
|
515
|
+
pass
|
|
516
|
+
return True, f"✓ {get_metadata(extension_id)['display_name']} enabled"
|
|
517
|
+
|
|
518
|
+
def disable(extension_id: str) -> tuple[bool, str]:
|
|
519
|
+
"""DISABLE: INSTALLED_ENABLED -> INSTALLED_DISABLED."""
|
|
520
|
+
data = _load_registry()
|
|
521
|
+
rec = data.get(extension_id)
|
|
522
|
+
if not rec or not rec.get("installed"):
|
|
523
|
+
return False, f"{extension_id} is not installed"
|
|
524
|
+
if not rec.get("enabled"):
|
|
525
|
+
return False, f"{extension_id} is already disabled"
|
|
526
|
+
rec["enabled"] = False
|
|
527
|
+
_save_registry(data)
|
|
528
|
+
if extension_id == "customization":
|
|
529
|
+
try:
|
|
530
|
+
from . import customization as _cust4
|
|
531
|
+
_cust4.on_disable()
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
return True, f"✓ {get_metadata(extension_id)['display_name']} disabled"
|
|
535
|
+
|
|
536
|
+
def toggle(extension_id: str) -> tuple[bool, str]:
|
|
537
|
+
if is_enabled(extension_id):
|
|
538
|
+
return disable(extension_id)
|
|
539
|
+
if is_installed(extension_id):
|
|
540
|
+
return enable(extension_id)
|
|
541
|
+
return install(extension_id)
|
|
542
|
+
|
|
543
|
+
# Backward compat for old simple API (bool)
|
|
544
|
+
def set_enabled(key: str, enabled: bool):
|
|
545
|
+
if enabled:
|
|
546
|
+
# if not installed, install
|
|
547
|
+
if not is_installed(key):
|
|
548
|
+
install(key)
|
|
549
|
+
else:
|
|
550
|
+
enable(key)
|
|
551
|
+
else:
|
|
552
|
+
# disable but keep installed
|
|
553
|
+
if is_installed(key):
|
|
554
|
+
disable(key)
|
|
555
|
+
else:
|
|
556
|
+
# if not installed, nothing to disable
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
def should_show_in_menu(action_id: str) -> bool:
|
|
560
|
+
"""For header.NAV_ITEMS filtering: show only if installed+enabled."""
|
|
561
|
+
if action_id in ("vision", "customization"):
|
|
562
|
+
return False
|
|
563
|
+
if action_id.startswith("ext_"):
|
|
564
|
+
key = action_id[4:]
|
|
565
|
+
return is_installed(key) and is_enabled(key)
|
|
566
|
+
# map action -> extension key
|
|
567
|
+
action_to_key = {v["action"]: k for k, v in BUILTIN_EXTENSIONS.items()}
|
|
568
|
+
key = action_to_key.get(action_id)
|
|
569
|
+
if key is None:
|
|
570
|
+
return True
|
|
571
|
+
return is_installed(key) and is_enabled(key)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def create_extension(
|
|
575
|
+
extension_id: str,
|
|
576
|
+
name: str = "",
|
|
577
|
+
description: str = "",
|
|
578
|
+
icon: str = "🧩",
|
|
579
|
+
publisher: str = "User",
|
|
580
|
+
category: str = "Custom",
|
|
581
|
+
version: str = "1.0.0",
|
|
582
|
+
script_code: str = "",
|
|
583
|
+
) -> tuple[bool, str]:
|
|
584
|
+
"""Create a new custom extension in ~/.cct_extensions/<id>/ and register it."""
|
|
585
|
+
try:
|
|
586
|
+
clean_id = (extension_id or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
587
|
+
if not clean_id:
|
|
588
|
+
return False, "Extension ID cannot be empty."
|
|
589
|
+
if clean_id in BUILTIN_EXTENSIONS:
|
|
590
|
+
return False, f"'{clean_id}' conflicts with a built-in extension."
|
|
591
|
+
|
|
592
|
+
ext_dir = CUSTOM_EXTENSIONS_DIR / clean_id
|
|
593
|
+
ext_dir.mkdir(parents=True, exist_ok=True)
|
|
594
|
+
|
|
595
|
+
display_name = (name or "").strip() or clean_id.title()
|
|
596
|
+
manifest = {
|
|
597
|
+
"id": clean_id,
|
|
598
|
+
"name": display_name,
|
|
599
|
+
"display_name": display_name,
|
|
600
|
+
"description": (description or "").strip() or "Custom user-created extension",
|
|
601
|
+
"icon": (icon or "").strip() or "🧩",
|
|
602
|
+
"publisher": (publisher or "").strip() or "User",
|
|
603
|
+
"publisher_type": "individual",
|
|
604
|
+
"publisher_verified": False,
|
|
605
|
+
"verified": False,
|
|
606
|
+
"trusted": True,
|
|
607
|
+
"version": (version or "").strip() or "1.0.0",
|
|
608
|
+
"category": (category or "").strip() or "Custom",
|
|
609
|
+
"keywords": ["custom", clean_id],
|
|
610
|
+
"kind": "custom",
|
|
611
|
+
"action": f"ext_{clean_id}",
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
manifest_file = ext_dir / "extension.json"
|
|
615
|
+
with open(manifest_file, "w", encoding="utf-8") as f:
|
|
616
|
+
json.dump(manifest, f, indent=2)
|
|
617
|
+
|
|
618
|
+
script_file = ext_dir / f"{clean_id}.py"
|
|
619
|
+
if not script_file.exists() or script_code:
|
|
620
|
+
code = script_code or f'"""\nCAT Extension: {display_name}\n"""\n\ndef activate(app=None):\n pass\n'
|
|
621
|
+
with open(script_file, "w", encoding="utf-8") as f:
|
|
622
|
+
f.write(code)
|
|
623
|
+
|
|
624
|
+
data = _load_registry()
|
|
625
|
+
data[clean_id] = {
|
|
626
|
+
"installed": True,
|
|
627
|
+
"enabled": True,
|
|
628
|
+
"version": manifest["version"],
|
|
629
|
+
"install_time": time.time(),
|
|
630
|
+
"size_bytes": _calc_size([ext_dir]),
|
|
631
|
+
"manifest": manifest,
|
|
632
|
+
}
|
|
633
|
+
_save_registry(data)
|
|
634
|
+
return True, f"✓ Custom extension '{display_name}' created and enabled successfully!"
|
|
635
|
+
except Exception as e:
|
|
636
|
+
return False, f"Failed to create extension: {e}"
|
|
637
|
+
|
|
638
|
+
# --------------------------------------------------------------------------
|
|
639
|
+
# Feature registration (for future extensions that add menu items, panels, etc.)
|
|
640
|
+
# --------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
_registered_features: Dict[str, Dict[str, Any]] = {}
|
|
643
|
+
|
|
644
|
+
def register_feature(extension_id: str, feature_type: str, feature_id: str, meta: Dict[str, Any] = None):
|
|
645
|
+
"""Extensions call this to register a feature (menu item, panel, command)."""
|
|
646
|
+
if not is_enabled(extension_id):
|
|
647
|
+
return False
|
|
648
|
+
key = f"{extension_id}:{feature_type}:{feature_id}"
|
|
649
|
+
_registered_features[key] = {"extension": extension_id, "type": feature_type, "id": feature_id, "meta": meta or {}}
|
|
650
|
+
return True
|
|
651
|
+
|
|
652
|
+
def unregister_feature(extension_id: str, feature_type: str, feature_id: str):
|
|
653
|
+
key = f"{extension_id}:{feature_type}:{feature_id}"
|
|
654
|
+
_registered_features.pop(key, None)
|
|
655
|
+
|
|
656
|
+
def get_registered_features(feature_type: str = None) -> List[Dict[str, Any]]:
|
|
657
|
+
if feature_type:
|
|
658
|
+
return [v for v in _registered_features.values() if v["type"] == feature_type]
|
|
659
|
+
return list(_registered_features.values())
|
|
660
|
+
|
|
661
|
+
# --------------------------------------------------------------------------
|
|
662
|
+
# Startup
|
|
663
|
+
# --------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def validate_all() -> List[str]:
|
|
666
|
+
"""Validate installed extensions; return list of errors (empty if ok)."""
|
|
667
|
+
errors = []
|
|
668
|
+
for ext in list_installed():
|
|
669
|
+
try:
|
|
670
|
+
# check that its module files still exist (for built-ins)
|
|
671
|
+
paths = _BUILTIN_PATHS.get(ext["id"], [])
|
|
672
|
+
if paths:
|
|
673
|
+
for p in paths:
|
|
674
|
+
if not p.exists():
|
|
675
|
+
errors.append(f"{ext['id']}: missing {p}")
|
|
676
|
+
except Exception as e:
|
|
677
|
+
errors.append(f"{ext['id']}: {e}")
|
|
678
|
+
return errors
|
|
679
|
+
|
|
680
|
+
def load_enabled():
|
|
681
|
+
"""Load enabled extensions at startup — register their features."""
|
|
682
|
+
for ext in list_enabled():
|
|
683
|
+
try:
|
|
684
|
+
# For built-ins, registration is implicit via should_show_in_menu;
|
|
685
|
+
# for future third-party, we would import and call their activate().
|
|
686
|
+
pass
|
|
687
|
+
except Exception as e:
|
|
688
|
+
# error isolation: don't crash CAT
|
|
689
|
+
print(f"[extensions] failed to load {ext['id']}: {e}")
|
|
690
|
+
|
|
691
|
+
def startup():
|
|
692
|
+
"""Full startup sequence: discover -> validate -> load enabled -> build menu."""
|
|
693
|
+
try:
|
|
694
|
+
discover()
|
|
695
|
+
errs = validate_all()
|
|
696
|
+
if errs:
|
|
697
|
+
for err in errs:
|
|
698
|
+
print(f"[extensions] validate: {err}")
|
|
699
|
+
load_enabled()
|
|
700
|
+
except Exception as e:
|
|
701
|
+
print(f"[extensions] startup failed: {e} — CAT will continue without extensions")
|
|
702
|
+
|
|
703
|
+
# Backwards compat aliases for old code that used simple enabled check
|
|
704
|
+
def enabled_actions() -> set:
|
|
705
|
+
return {v["action"] for v in BUILTIN_EXTENSIONS.values() if is_enabled(v["id"])}
|
|
706
|
+
|
|
707
|
+
# For tests: reset to defaults (used in testing)
|
|
708
|
+
def _reset_for_tests():
|
|
709
|
+
try:
|
|
710
|
+
if REGISTRY_FILE.exists():
|
|
711
|
+
REGISTRY_FILE.unlink()
|
|
712
|
+
except Exception:
|
|
713
|
+
pass
|
|
714
|
+
# clear legacy config state too
|
|
715
|
+
try:
|
|
716
|
+
from . import config as _cfg
|
|
717
|
+
cfg = _cfg.load_config()
|
|
718
|
+
cfg.extensions = {}
|
|
719
|
+
# also clear registry snapshot in config if any
|
|
720
|
+
if hasattr(cfg, "user_modes"):
|
|
721
|
+
# don't clear user modes here - that's for ai_modes, not extensions
|
|
722
|
+
pass
|
|
723
|
+
_cfg.save_config(cfg)
|
|
724
|
+
except Exception:
|
|
725
|
+
pass
|
|
726
|
+
global _cached_state, _cached_mtime
|
|
727
|
+
_cached_state = None
|
|
728
|
+
_cached_mtime = 0
|
|
729
|
+
# re-create with all installed+enabled
|
|
730
|
+
_load_registry()
|
|
731
|
+
|
|
732
|
+
def _clear_all_for_test():
|
|
733
|
+
_reset_for_tests()
|