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.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,593 @@
1
+ """
2
+ CCT Project Workspace (spec section 16).
3
+
4
+ Before this module, generated content scattered across at least two
5
+ independently-hardcoded, inconsistently-named directories:
6
+ - calc_terminal/scires.py wrote PNG snapshots to ~/cct_exports
7
+ - calc_terminal/report.py wrote notebook exports to ~/CCT_exports
8
+ (note the casing difference alone — these were never the same folder,
9
+ even by accident, on a case-sensitive filesystem).
10
+
11
+ This module gives every generated artifact ONE home:
12
+ <config.workspace_directory>/<category>/<filename>
13
+ using the workspace_directory that's already been a real, working
14
+ config.py field since the last session (Settings item 8). Nothing
15
+ generated by AI/commands should "disappear into chat history" the way
16
+ the spec puts it — it should land in a place the person can browse.
17
+
18
+ Categories match the spec's list (section 16): simulations, images,
19
+ graphs, reports, scripts, research, calculations, projects. Not every
20
+ category has a producer wired up yet — see WIRED_INTO below for the
21
+ honest current state.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import time
27
+ from . import config as cctconfig
28
+
29
+ CATEGORIES = [
30
+ "simulations", "images", "graphs", "reports",
31
+ "scripts", "research", "calculations", "projects",
32
+ ]
33
+
34
+ # Set by ui/app.py._open_folder()/_close_folder() whenever the user opens
35
+ # or closes a project via the Explorer sidebar's "Open Folder". When a
36
+ # project is open, it — not the fixed config.workspace_directory — is
37
+ # what root_dir()/resolve_writable_path() treat as "the workspace", so
38
+ # relative paths the AI creates ("demo.py", "src/main.py", ...) land
39
+ # inside the project the user actually has open in the File Explorer
40
+ # instead of a separate, always-there ~/cct_workspace folder the user
41
+ # never sees on screen. This was the literal bug in the review: AI
42
+ # writes always resolved to a hardcoded directory regardless of which
43
+ # folder was open. `None` (the default, and what a fresh session/the
44
+ # classic non-UI REPL always has) means "no project open" and root_dir()
45
+ # falls back to config.workspace_directory exactly as it always did —
46
+ # no behavior change for anyone who never opens a folder.
47
+ _active_project_root = None
48
+
49
+
50
+ def set_active_project(path):
51
+ """Called by the UI the moment a folder is opened/closed via the
52
+ Explorer sidebar. `path=None` clears it back to the configured
53
+ workspace_directory fallback."""
54
+ global _active_project_root
55
+ if path is None:
56
+ _active_project_root = None
57
+ return
58
+ _active_project_root = os.path.abspath(os.path.expanduser(str(path)))
59
+
60
+
61
+ def active_project():
62
+ """The currently open IDE project folder, or None if none is open."""
63
+ return _active_project_root
64
+
65
+ # What's actually wired into the workspace right now vs. still landing
66
+ # in an ad hoc location. Read by cmd_workspace so the UI can say so
67
+ # honestly instead of implying every category is populated.
68
+ WIRED_INTO = {
69
+ "simulations": "calc_terminal/scires.py — render_atom(), render_orbital()",
70
+ "graphs": "calc_terminal/scires.py — render_orbital_grid(), render_bonding()",
71
+ "reports": "calc_terminal/report.py — export_pdf() / export_markdown() (via default_export_dir)",
72
+ "images": None,
73
+ "scripts": None,
74
+ "research": None,
75
+ "calculations": None,
76
+ "projects": None,
77
+ }
78
+
79
+
80
+ # ---------------------------------------------------------------- STRICT FIX v0.7.9.6 --
81
+ # File-creation permission model (spec §14): EXPLICIT_ONLY.
82
+ # A user-provided path means "work here", not "populate this location".
83
+ # category_dir() no longer creates directories as a side effect of being
84
+ # read; only an explicit write (ensure=True / path_for / ensure_category_dir)
85
+ # may create a directory. summary()/list_category() therefore never
86
+ # scaffold unwanted folders inside an existing project.
87
+ FILE_CREATION_POLICY = "EXPLICIT_ONLY"
88
+
89
+
90
+ def root_dir(*, ensure=False):
91
+ """The active workspace root: the currently open IDE project folder
92
+ if one is open (see set_active_project()), otherwise the configured
93
+ workspace_directory fallback.
94
+
95
+ STRICT FIX v0.7.9.6: directory creation is now explicit-only. Callers
96
+ that intend to WRITE must pass ensure=True; read-only callers
97
+ (summary, list_category, path resolution for reads) must leave
98
+ ensure=False (the default) so merely inspecting or opening an
99
+ existing folder never creates files or subdirectories inside it.
100
+ When ensure=False the directory is returned verbatim even if it does
101
+ not yet exist — callers that need to list it handle the missing-dir
102
+ case themselves (returning an empty listing).
103
+
104
+ v0.7.9.0 fix preserved: creation is best-effort."""
105
+ if _active_project_root is not None:
106
+ d = _active_project_root
107
+ else:
108
+ cfg = cctconfig.get_config()
109
+ d = os.path.expanduser(cfg.workspace_directory)
110
+ if ensure:
111
+ try:
112
+ os.makedirs(d, exist_ok=True)
113
+ except OSError:
114
+ pass # unwritable/protected root — callers fail honestly per-op
115
+ return d
116
+
117
+
118
+ def category_dir(category, *, ensure=False):
119
+ """Return the path for a workspace category.
120
+
121
+ STRICT FIX v0.7.9.6: by default does NOT create the directory. Pass
122
+ ensure=True only when the caller is about to write a file there
123
+ (path_for, default_export_dir, scires._export_dir). Read paths
124
+ (list_category, summary) use ensure=False so dashboards and
125
+ inspections never scaffold the 8 default folders inside a user's
126
+ existing project."""
127
+ if category not in CATEGORIES:
128
+ raise ValueError(f"Unknown workspace category: {category!r}. Valid: {CATEGORIES}")
129
+ d = os.path.join(root_dir(ensure=ensure), category)
130
+ if ensure:
131
+ try:
132
+ os.makedirs(d, exist_ok=True)
133
+ except OSError:
134
+ pass # see root_dir(): creation is best-effort, never fatal
135
+ return d
136
+
137
+
138
+ def ensure_category_dir(category):
139
+ """Explicit creation helper — only call when the user explicitly
140
+ requested a file in this category or the requested operation
141
+ necessarily requires it. Returns the ensured directory."""
142
+ return category_dir(category, ensure=True)
143
+
144
+
145
+ def path_for(category, filename):
146
+ """Resolve (and ensure the directory exists for) a file that's
147
+ about to be written into the workspace. Callers still do their own
148
+ actual file writing — this just gives them the one correct path.
149
+
150
+ This is an EXPLICIT creation path — the caller's intent is to write,
151
+ so the category directory is ensured."""
152
+ return os.path.join(category_dir(category, ensure=True), filename)
153
+
154
+
155
+ # Directories a write/delete/rename should never resolve into, even if
156
+ # the model asks nicely with a plausible-sounding reason — a narrow,
157
+ # named blocklist, not a claim of a full sandbox (spec section 20 asks
158
+ # for "prevent path traversal", not "prevent all filesystem access").
159
+ _BLOCKED_ROOTS = (
160
+ "/etc", "/sys", "/proc", "/boot", "/dev", "/System", "/Library",
161
+ "C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)",
162
+ )
163
+
164
+
165
+ def find_matching_workspace_path(query_path: str, base_dir: str = None) -> str:
166
+ """Automate file path resolution: find real matching file in workspace.
167
+
168
+ If query_path is 'index.html' or 'calculator/index.html', but the actual file
169
+ is at '<base>/projects/calculator/index.html', finds and returns the real path.
170
+ """
171
+ if not query_path or not str(query_path).strip():
172
+ return ""
173
+ base = base_dir or root_dir()
174
+ if not os.path.isdir(base):
175
+ return ""
176
+
177
+ raw_clean = str(query_path).strip().replace("\\", "/")
178
+ # If starts with fake roots like /workspace/ or /project/, strip them
179
+ for prefix in ("/workspace/", "workspace/", "/project/", "project/"):
180
+ if raw_clean.lower().startswith(prefix):
181
+ raw_clean = raw_clean[len(prefix):]
182
+ break
183
+
184
+ clean_query = os.path.normpath(raw_clean.lstrip("/\\"))
185
+ # 1. Direct path check
186
+ direct = os.path.normpath(os.path.join(base, clean_query))
187
+ if os.path.exists(direct):
188
+ return direct
189
+
190
+ # 2. Search workspace
191
+ target_name = os.path.basename(clean_query).lower()
192
+ target_subpath = clean_query.replace("\\", "/").lower()
193
+
194
+ exact_name_matches = []
195
+ suffix_matches = []
196
+ fuzzy_matches = []
197
+
198
+ skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv", ".pytest_cache", "dist", "build"}
199
+ for root, dirs, files in os.walk(base):
200
+ dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
201
+ # Also check directories if query might be a folder
202
+ for d in dirs:
203
+ full_d = os.path.join(root, d)
204
+ rel_d = os.path.relpath(full_d, base).replace("\\", "/").lower()
205
+ if rel_d == target_subpath or rel_d.endswith("/" + target_subpath) or d.lower() == target_name:
206
+ fuzzy_matches.append(full_d)
207
+
208
+ for f in files:
209
+ full_f = os.path.join(root, f)
210
+ rel_f = os.path.relpath(full_f, base).replace("\\", "/").lower()
211
+ if rel_f.endswith(target_subpath):
212
+ suffix_matches.append(full_f)
213
+ elif f.lower() == target_name:
214
+ exact_name_matches.append(full_f)
215
+ elif target_name in f.lower() or target_subpath in rel_f:
216
+ fuzzy_matches.append(full_f)
217
+
218
+ if suffix_matches:
219
+ return suffix_matches[0]
220
+ if exact_name_matches:
221
+ return exact_name_matches[0]
222
+ if fuzzy_matches:
223
+ return fuzzy_matches[0]
224
+ return ""
225
+
226
+
227
+ def resolve_writable_path(path):
228
+ """Turn a model- or user-supplied path into a safe absolute path,
229
+ for use by anything that creates/writes/deletes/renames a real
230
+ file (calc_terminal/agent.py's write_file/delete_file/rename_file/
231
+ create_folder tools).
232
+
233
+ - A relative path resolves against the workspace root (root_dir()),
234
+ not the process's current working directory, so "src/main.py"
235
+ means the same real location regardless of how CCT was launched.
236
+ - Automated path resolution: if the path is relative or points to
237
+ a subfolder file like "index.html" or "calculator/index.html",
238
+ it scans the active workspace to find the real matching path.
239
+ - An absolute path is honored as given (normalized) — same as any
240
+ other AI-IDE agent operating with the OS user's own permissions.
241
+ - Raises ValueError (never silently substitutes a "safe" path) if
242
+ the resolved path lands in a clearly-dangerous system root, or
243
+ outside of both the workspace root and the user's home directory
244
+ — the two places CCT has any real business writing.
245
+ """
246
+ if not path or not str(path).strip():
247
+ raise ValueError("No path given.")
248
+ expanded = os.path.expanduser(str(path).strip())
249
+ base = root_dir()
250
+
251
+ # If exact absolute path exists, use it
252
+ if os.path.isabs(expanded) and os.path.exists(expanded):
253
+ resolved = os.path.normpath(expanded)
254
+ else:
255
+ # Automated path resolution: match file in workspace
256
+ auto_found = find_matching_workspace_path(expanded, base)
257
+ if auto_found:
258
+ resolved = auto_found
259
+ elif os.path.isabs(expanded):
260
+ resolved = os.path.normpath(expanded)
261
+ else:
262
+ resolved = os.path.normpath(os.path.join(base, expanded))
263
+
264
+ for blocked in _BLOCKED_ROOTS:
265
+ blocked_norm = _norm_ci(blocked)
266
+ if resolved_ci := _norm_ci(resolved):
267
+ if resolved_ci == blocked_norm or resolved_ci.startswith(blocked_norm + os.sep):
268
+ raise ValueError(f"Refusing to touch a protected system path: {resolved}")
269
+
270
+ home = os.path.expanduser("~")
271
+ in_workspace = resolved == base or resolved.startswith(base + os.sep)
272
+ in_home = resolved == home or resolved.startswith(home + os.sep)
273
+ if not (in_workspace or in_home):
274
+ raise ValueError(
275
+ f"'{resolved}' is outside both the workspace ({base}) and your home "
276
+ "directory — refusing to write there. Use a path under one of those."
277
+ )
278
+ return resolved
279
+
280
+
281
+ def _norm_ci(path):
282
+ """Normalized + case-insensitive form of a path for protected-root
283
+ comparison. Windows filesystems are case-insensitive ('C:/WINDOWS'
284
+ and 'C:/Windows' are the same directory), so every blocklist
285
+ comparison must go through this — a plain startswith() silently
286
+ missed C:/WINDOWS/System32."""
287
+ return os.path.normcase(os.path.normpath(os.path.abspath(
288
+ os.path.expanduser(str(path)))))
289
+
290
+
291
+ def path_is_protected(path):
292
+ """True when `path` sits inside (or equals) one of the protected
293
+ system roots — used by projects.forget_protected() to purge bad
294
+ recent-workspace entries and by detect_workspace() to refuse
295
+ adopting e.g. C:\\WINDOWS\\System32 as a workspace. Never raises.
296
+ Comparison is case-insensitive via _norm_ci()."""
297
+ try:
298
+ norm = _norm_ci(path)
299
+ except Exception:
300
+ return False
301
+ for blocked in _BLOCKED_ROOTS:
302
+ b = _norm_ci(blocked)
303
+ if norm == b or norm.startswith(b + os.sep):
304
+ return True
305
+ return False
306
+
307
+
308
+ def resolve_readable_path(path):
309
+ """Resolve a path for READ-ONLY tool access (read_file, archive_list,
310
+ archive_validate, search). Read-only access is safe anywhere except
311
+ the named protected system roots, so this accepts absolute paths
312
+ anywhere on disk — an attached file on another drive can be listed
313
+ without first being copied into the workspace. Raises ValueError for
314
+ blocked roots and empty paths only."""
315
+ if not path or not str(path).strip():
316
+ raise ValueError("No path given.")
317
+ expanded = os.path.expanduser(str(path).strip())
318
+ base = root_dir()
319
+
320
+ if os.path.isabs(expanded) and os.path.exists(expanded):
321
+ resolved = os.path.normpath(expanded)
322
+ else:
323
+ auto_found = find_matching_workspace_path(expanded, base)
324
+ if auto_found:
325
+ resolved = auto_found
326
+ elif os.path.isabs(expanded):
327
+ resolved = os.path.normpath(expanded)
328
+ else:
329
+ resolved = os.path.normpath(os.path.join(base, expanded))
330
+
331
+ for blocked in _BLOCKED_ROOTS:
332
+ blocked_norm = _norm_ci(blocked)
333
+ resolved_ci = _norm_ci(resolved)
334
+ if resolved_ci == blocked_norm or resolved_ci.startswith(blocked_norm + os.sep):
335
+ raise ValueError(f"Refusing to read a protected system path: {resolved}")
336
+ return resolved
337
+
338
+
339
+ def resolve_tool_path(path, for_write=False):
340
+ """Resolve a model-supplied path for a tool call: strict
341
+ resolve_writable_path rules for mutations, relaxed read-only rules
342
+ (any non-blocked location) for reads."""
343
+ if for_write:
344
+ return resolve_writable_path(path)
345
+ try:
346
+ return resolve_writable_path(path)
347
+ except ValueError:
348
+ return resolve_readable_path(path)
349
+
350
+
351
+ def list_category(category):
352
+ d = category_dir(category)
353
+ out = []
354
+ try:
355
+ names = sorted(os.listdir(d))
356
+ except OSError:
357
+ # Unreadable/uncreatable category dir (e.g. CAT launched from a
358
+ # protected directory) — an empty listing, never a crash.
359
+ return out
360
+ for name in names:
361
+ full = os.path.join(d, name)
362
+ try:
363
+ if os.path.isfile(full):
364
+ st = os.stat(full)
365
+ out.append({
366
+ "name": name,
367
+ "path": full,
368
+ "size": st.st_size,
369
+ "mtime": st.st_mtime,
370
+ })
371
+ except OSError:
372
+ continue
373
+ return out
374
+
375
+
376
+ def list_all():
377
+ return {cat: list_category(cat) for cat in CATEGORIES}
378
+
379
+
380
+ def summary():
381
+ """One line per non-empty category, newest file first-ish, for a
382
+ quick /workspace overview without dumping every file.
383
+
384
+ v0.7.9.0 fix: per-category failures (PermissionError et al.) are
385
+ skipped, so the startup dashboard can always compose — launching
386
+ `cat` from C:\\WINDOWS\\System32 used to crash the whole app here."""
387
+ lines = []
388
+ for cat in CATEGORIES:
389
+ try:
390
+ files = list_category(cat)
391
+ except Exception:
392
+ continue
393
+ if not files:
394
+ continue
395
+ files.sort(key=lambda f: f["mtime"], reverse=True)
396
+ newest = files[0]
397
+ age = time.time() - newest["mtime"]
398
+ when = "just now" if age < 60 else f"{int(age // 60)}m ago" if age < 3600 else f"{int(age // 3600)}h ago"
399
+ lines.append((cat, len(files), newest["name"], when))
400
+ return lines
401
+
402
+
403
+ # =============================================== v0.7.7 workspace preview ==
404
+ # Automatic workspace detection (spec section 1) + git helpers used by
405
+ # the Project Header (spec section 9). Everything below is additive.
406
+
407
+
408
+ def app_directory():
409
+ """The CCT application's own source directory (the folder that
410
+ holds main.py + calc_terminal/). Used by detection to avoid
411
+ declaring CCT itself to be the user's workspace."""
412
+ try:
413
+ base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
414
+ if os.path.exists(os.path.join(base, "main.py")):
415
+ return os.path.abspath(base)
416
+ except Exception:
417
+ pass
418
+ return os.path.abspath(os.getcwd())
419
+
420
+
421
+ def git_root(start=None):
422
+ """The nearest enclosing git repository root for `start` (or the
423
+ current working directory), via `git rev-parse --show-toplevel`.
424
+ Returns None when git isn't installed or `start` isn't in a
425
+ repository — never raises."""
426
+ start = os.path.abspath(start or os.getcwd())
427
+ import subprocess
428
+ try:
429
+ out = subprocess.run(
430
+ ["git", "-C", start, "rev-parse", "--show-toplevel"],
431
+ capture_output=True, text=True, timeout=15,
432
+ encoding="utf-8", errors="replace")
433
+ if out.returncode == 0:
434
+ root = out.stdout.strip()
435
+ return os.path.abspath(root) if root else None
436
+ except Exception:
437
+ pass
438
+ return None
439
+
440
+
441
+ def git_branch(path):
442
+ """Current branch of the repo containing `path`, or None."""
443
+ import subprocess
444
+ try:
445
+ out = subprocess.run(
446
+ ["git", "-C", path, "branch", "--show-current"],
447
+ capture_output=True, text=True, timeout=15,
448
+ encoding="utf-8", errors="replace")
449
+ branch = out.stdout.strip()
450
+ return branch or None
451
+ except Exception:
452
+ return None
453
+
454
+
455
+ def git_status_summary(path):
456
+ """Short `git status --porcelain` summary: (changed, added,
457
+ deleted) counts, or None when not a repo. Honest — counts real
458
+ porcelain entries only."""
459
+ import subprocess
460
+ try:
461
+ out = subprocess.run(
462
+ ["git", "-C", path, "status", "--porcelain"],
463
+ capture_output=True, text=True, timeout=20,
464
+ encoding="utf-8", errors="replace")
465
+ if out.returncode != 0:
466
+ return None
467
+ changed = added = deleted = 0
468
+ for line in out.stdout.splitlines():
469
+ code = line[:2].strip()
470
+ if code.startswith("A") or code.startswith("??"):
471
+ added += 1
472
+ elif code.startswith("D"):
473
+ deleted += 1
474
+ elif code:
475
+ changed += 1
476
+ return {"changed": changed, "added": added, "deleted": deleted}
477
+ except Exception:
478
+ return None
479
+
480
+
481
+ def _vscode_workspace(cwd):
482
+ """VS Code currently-open workspace (spec section 1, priority 2):
483
+ a `*.code-workspace` file in the working directory (its "folders"
484
+ paths are the real workspace folders), else a `.vscode/` marker
485
+ folder meaning "this directory is the VS Code workspace". Returns
486
+ the first valid folder path, or None."""
487
+ try:
488
+ for name in sorted(os.listdir(cwd)):
489
+ if name.endswith(".code-workspace"):
490
+ full = os.path.join(cwd, name)
491
+ try:
492
+ with open(full, "r", encoding="utf-8") as f:
493
+ import json
494
+ data = json.load(f)
495
+ for folder in (data or {}).get("folders", []):
496
+ p = folder.get("path", "")
497
+ if not p:
498
+ continue
499
+ p = os.path.abspath(os.path.join(cwd, p))
500
+ if os.path.isdir(p):
501
+ return p
502
+ except Exception:
503
+ continue
504
+ return cwd
505
+ if os.path.isdir(os.path.join(cwd, ".vscode")):
506
+ return cwd
507
+ except Exception:
508
+ pass
509
+ return None
510
+
511
+
512
+ def detect_workspace(cwd=None):
513
+ """Automatic workspace detection (spec section 1) — returns the
514
+ first valid workspace path in this priority order, or None:
515
+
516
+ 1. the current terminal working directory (when it isn't CCT's
517
+ own app folder — a project-looking cwd is the workspace);
518
+ 2. the VS Code workspace (.code-workspace file / .vscode marker);
519
+ 3. the git repository root;
520
+ 4. the directory CCT was launched from;
521
+ 5. the last opened workspace (projects.last_opened()).
522
+
523
+ Pure function: never mutates state, never writes files. Callers
524
+ decide what to do with the result.
525
+
526
+ v0.7.9.0 fix: candidates inside protected system roots
527
+ (C:\\Windows, C:\\Program Files, ...) are skipped entirely —
528
+ launching `cat` from PowerShell's default C:\\WINDOWS\\System32 used
529
+ to adopt System32 as the workspace (and even record it into recent
530
+ history), which then crashed generated-file creation with
531
+ PermissionError."""
532
+ cwd = os.path.abspath(cwd or os.getcwd())
533
+ app_dir = app_directory()
534
+ try:
535
+ def _protected(path):
536
+ norm = _norm_ci(path)
537
+ for blocked in _BLOCKED_ROOTS:
538
+ b = _norm_ci(blocked)
539
+ if norm == b or norm.startswith(b + os.sep):
540
+ return True
541
+ return False
542
+
543
+ candidates = []
544
+ # 1 — cwd itself, unless it IS the app or a protected system dir.
545
+ if cwd != app_dir and not _protected(cwd):
546
+ candidates.append(cwd)
547
+ # 2 — VS Code workspace.
548
+ vsc = _vscode_workspace(cwd)
549
+ if vsc and vsc not in candidates:
550
+ candidates.append(vsc)
551
+ # 3 — git root.
552
+ gr = git_root(cwd)
553
+ if gr and gr not in candidates:
554
+ candidates.append(gr)
555
+ # 4 — launch directory (when it differs from cwd and isn't
556
+ # the app folder).
557
+ try:
558
+ launch = os.path.abspath(os.path.dirname(sys.argv[0]) or cwd)
559
+ if launch != cwd and launch != app_dir and launch not in candidates:
560
+ candidates.append(launch)
561
+ except Exception:
562
+ pass
563
+ # 5 — last opened workspace.
564
+ try:
565
+ from . import projects
566
+ last = projects.last_opened()
567
+ if last and last not in candidates:
568
+ candidates.append(last)
569
+ except Exception:
570
+ pass
571
+ for path in candidates:
572
+ if os.path.isdir(path) and not _protected(path):
573
+ return os.path.abspath(path)
574
+ except Exception:
575
+ pass
576
+ return None
577
+
578
+
579
+ def auto_load_workspace():
580
+ """Run detection once at startup and, if a workspace was found,
581
+ load it: record it in recent history and make it the active
582
+ project root (the same state _open_folder would set). Returns the
583
+ detected path, or None. Idempotent — safe to call from both UIs."""
584
+ try:
585
+ path = detect_workspace()
586
+ if not path:
587
+ return None
588
+ from . import projects
589
+ projects.record_opened(path)
590
+ set_active_project(path)
591
+ return path
592
+ except Exception:
593
+ return None