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,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
|