ccdock 0.1.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.
- ccdock/__init__.py +3 -0
- ccdock/__main__.py +61 -0
- ccdock/envs.py +188 -0
- ccdock/launcher.py +271 -0
- ccdock/scanner.py +147 -0
- ccdock/server.py +210 -0
- ccdock/sizes.py +151 -0
- ccdock/store.py +71 -0
- ccdock/web/index.html +607 -0
- ccdock-0.1.0.dist-info/LICENSE +21 -0
- ccdock-0.1.0.dist-info/METADATA +131 -0
- ccdock-0.1.0.dist-info/RECORD +15 -0
- ccdock-0.1.0.dist-info/WHEEL +5 -0
- ccdock-0.1.0.dist-info/entry_points.txt +2 -0
- ccdock-0.1.0.dist-info/top_level.txt +1 -0
ccdock/__init__.py
ADDED
ccdock/__main__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""ccdock 진입점."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__, launcher, scanner, server, store
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _safe_console():
|
|
10
|
+
"""cp949 콘솔에서도 한글 출력이 예외로 죽지 않게 한다."""
|
|
11
|
+
for stream in (sys.stdout, sys.stderr):
|
|
12
|
+
try:
|
|
13
|
+
stream.reconfigure(errors="replace")
|
|
14
|
+
except (AttributeError, ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cmd_list(args):
|
|
19
|
+
for i, p in enumerate(scanner.collect(), 1):
|
|
20
|
+
print("%3d %-38s %-10s %s" % (
|
|
21
|
+
i, p["name"], scanner.relative_time(p["mtime"]), p["env"] or "-"))
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cmd_workspace(args):
|
|
26
|
+
cfg = store.load()
|
|
27
|
+
items = cfg.get("workspaces", {}).get(args.name)
|
|
28
|
+
if not items:
|
|
29
|
+
names = ", ".join(cfg.get("workspaces", {})) or "(없음)"
|
|
30
|
+
print("워크스페이스를 찾을 수 없습니다: %s" % args.name, file=sys.stderr)
|
|
31
|
+
print("저장된 워크스페이스: %s" % names, file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
for r in launcher.launch_many(items, args.mode):
|
|
34
|
+
print((" OK " if r.get("ok") else " 실패 ") + str(r.get("path")))
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv=None):
|
|
39
|
+
ap = argparse.ArgumentParser(
|
|
40
|
+
prog="ccdock", description="Claude Code 프로젝트/가상환경 런처 대시보드")
|
|
41
|
+
ap.add_argument("-V", "--version", action="version", version="ccdock " + __version__)
|
|
42
|
+
ap.add_argument("-p", "--port", type=int, default=None, help="대시보드 포트")
|
|
43
|
+
ap.add_argument("-n", "--no-browser", action="store_true", help="브라우저를 열지 않음")
|
|
44
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
45
|
+
|
|
46
|
+
sub.add_parser("list", help="프로젝트 목록을 터미널에 출력")
|
|
47
|
+
w = sub.add_parser("workspace", help="저장된 워크스페이스 실행")
|
|
48
|
+
w.add_argument("name")
|
|
49
|
+
w.add_argument("-m", "--mode", choices=("window", "tab"), default="window")
|
|
50
|
+
|
|
51
|
+
args = ap.parse_args(argv)
|
|
52
|
+
_safe_console()
|
|
53
|
+
if args.cmd == "list":
|
|
54
|
+
return _cmd_list(args)
|
|
55
|
+
if args.cmd == "workspace":
|
|
56
|
+
return _cmd_workspace(args)
|
|
57
|
+
return server.serve(port=args.port, open_browser=not args.no_browser)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
sys.exit(main())
|
ccdock/envs.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""conda 가상환경 목록과 activate 스크립트 위치를 찾는다."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
HOME = Path.home()
|
|
10
|
+
_cache = {"at": 0.0, "envs": None, "root": None}
|
|
11
|
+
_TTL = 60.0
|
|
12
|
+
|
|
13
|
+
_ROOT_CANDIDATES = (
|
|
14
|
+
"anaconda3", "miniconda3", "Anaconda3", "Miniconda3",
|
|
15
|
+
"AppData/Local/anaconda3", "AppData/Local/miniconda3",
|
|
16
|
+
"mambaforge", "miniforge3",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def conda_root():
|
|
21
|
+
"""conda 설치 루트(base 경로)를 찾는다."""
|
|
22
|
+
if _cache["root"]:
|
|
23
|
+
return _cache["root"]
|
|
24
|
+
cands = []
|
|
25
|
+
for var in ("CONDA_ROOT", "CONDA_PREFIX_1", "CONDA_EXE"):
|
|
26
|
+
v = os.environ.get(var)
|
|
27
|
+
if not v:
|
|
28
|
+
continue
|
|
29
|
+
p = Path(v)
|
|
30
|
+
cands.append(p.parents[2] if var == "CONDA_EXE" and len(p.parents) > 2 else p)
|
|
31
|
+
for name in _ROOT_CANDIDATES:
|
|
32
|
+
cands.append(HOME / name)
|
|
33
|
+
cands.append(Path("C:/ProgramData/anaconda3"))
|
|
34
|
+
for c in cands:
|
|
35
|
+
try:
|
|
36
|
+
if (c / "Scripts" / "activate.bat").is_file():
|
|
37
|
+
_cache["root"] = c
|
|
38
|
+
return c
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def activate_bat():
|
|
45
|
+
root = conda_root()
|
|
46
|
+
return str(root / "Scripts" / "activate.bat") if root else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _from_environments_txt():
|
|
50
|
+
"""~/.conda/environments.txt - conda 를 실행하지 않고 즉시 읽는 빠른 경로."""
|
|
51
|
+
f = HOME / ".conda" / "environments.txt"
|
|
52
|
+
if not f.is_file():
|
|
53
|
+
return []
|
|
54
|
+
out = []
|
|
55
|
+
try:
|
|
56
|
+
for line in f.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line and os.path.isdir(line):
|
|
59
|
+
out.append(line)
|
|
60
|
+
except OSError:
|
|
61
|
+
return []
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _from_envs_dir():
|
|
66
|
+
root = conda_root()
|
|
67
|
+
if not root:
|
|
68
|
+
return []
|
|
69
|
+
out = [str(root)]
|
|
70
|
+
envs = root / "envs"
|
|
71
|
+
if envs.is_dir():
|
|
72
|
+
try:
|
|
73
|
+
out += [str(d) for d in envs.iterdir() if (d / "python.exe").is_file() or d.is_dir()]
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _name_of(prefix, root):
|
|
80
|
+
p = Path(prefix)
|
|
81
|
+
if root and p == Path(root):
|
|
82
|
+
return "base"
|
|
83
|
+
return p.name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_venv_id(env_id):
|
|
87
|
+
"""환경 식별자가 conda 이름인지 venv 경로인지 구분한다."""
|
|
88
|
+
s = str(env_id or "")
|
|
89
|
+
return ("\\" in s) or ("/" in s)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def venv_activate(prefix):
|
|
93
|
+
return str(Path(prefix) / "Scripts" / "activate.bat")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_venv(path):
|
|
97
|
+
p = Path(path)
|
|
98
|
+
try:
|
|
99
|
+
return (p / "pyvenv.cfg").is_file() and (p / "Scripts" / "activate.bat").is_file()
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan_venvs(search_roots):
|
|
105
|
+
"""프로젝트 폴더 바로 아래에 있는 venv 를 찾는다 (.venv, venv, *-env 등)."""
|
|
106
|
+
out, seen = [], set()
|
|
107
|
+
for root in search_roots or ():
|
|
108
|
+
try:
|
|
109
|
+
if not os.path.isdir(root):
|
|
110
|
+
continue
|
|
111
|
+
for d in os.scandir(root):
|
|
112
|
+
if not d.is_dir():
|
|
113
|
+
continue
|
|
114
|
+
k = d.path.lower().rstrip("\\/")
|
|
115
|
+
if k in seen or not _is_venv(d.path):
|
|
116
|
+
continue
|
|
117
|
+
seen.add(k)
|
|
118
|
+
out.append({
|
|
119
|
+
"kind": "venv",
|
|
120
|
+
"id": d.path,
|
|
121
|
+
"name": d.name,
|
|
122
|
+
"prefix": d.path,
|
|
123
|
+
"where": os.path.basename(root) or root,
|
|
124
|
+
"python": (Path(d.path) / "Scripts" / "python.exe").is_file(),
|
|
125
|
+
})
|
|
126
|
+
except OSError:
|
|
127
|
+
continue
|
|
128
|
+
out.sort(key=lambda e: (e["name"].lower(), e["where"].lower()))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_conda_envs(force=False):
|
|
133
|
+
"""conda 환경 목록. [{'kind':'conda','id':이름,'name':이름,'prefix':...}]"""
|
|
134
|
+
now = time.time()
|
|
135
|
+
if not force and _cache["envs"] is not None and now - _cache["at"] < _TTL:
|
|
136
|
+
return _cache["envs"]
|
|
137
|
+
|
|
138
|
+
root = conda_root()
|
|
139
|
+
prefixes = _from_environments_txt() or _from_envs_dir()
|
|
140
|
+
seen, out = set(), []
|
|
141
|
+
for pre in prefixes:
|
|
142
|
+
k = str(pre).lower().rstrip("\\/")
|
|
143
|
+
if k in seen:
|
|
144
|
+
continue
|
|
145
|
+
seen.add(k)
|
|
146
|
+
name = _name_of(pre, root)
|
|
147
|
+
py = Path(pre) / "python.exe"
|
|
148
|
+
out.append({
|
|
149
|
+
"kind": "conda",
|
|
150
|
+
"id": name,
|
|
151
|
+
"name": name,
|
|
152
|
+
"prefix": str(pre),
|
|
153
|
+
"where": "conda",
|
|
154
|
+
"python": py.is_file(),
|
|
155
|
+
})
|
|
156
|
+
out.sort(key=lambda e: (e["name"] != "base", e["name"].lower()))
|
|
157
|
+
_cache["envs"], _cache["at"] = out, now
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _size_exclusions(entry):
|
|
162
|
+
"""base 환경은 envs/ 와 pkgs/ 를 빼야 자기 자신의 크기가 나온다."""
|
|
163
|
+
if entry["kind"] == "conda" and entry["name"] == "base":
|
|
164
|
+
root = Path(entry["prefix"])
|
|
165
|
+
return (str(root / "envs"), str(root / "pkgs"))
|
|
166
|
+
return ()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_envs(search_roots=None, force=False, with_size=True):
|
|
170
|
+
"""conda 환경과 프로젝트 폴더의 venv 를 합쳐 돌려준다."""
|
|
171
|
+
from . import sizes
|
|
172
|
+
|
|
173
|
+
out = [dict(e) for e in list_conda_envs(force=force)]
|
|
174
|
+
known = {e["id"].lower() for e in out}
|
|
175
|
+
for v in scan_venvs(search_roots or []):
|
|
176
|
+
if v["id"].lower() not in known:
|
|
177
|
+
out.append(v)
|
|
178
|
+
|
|
179
|
+
if with_size:
|
|
180
|
+
for e in out:
|
|
181
|
+
s = sizes.get(e["prefix"], _size_exclusions(e))
|
|
182
|
+
e["size"] = s["bytes"] if s else None
|
|
183
|
+
e["files"] = s["files"] if s else None
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def env_names():
|
|
188
|
+
return [e["name"] for e in list_conda_envs()]
|
ccdock/launcher.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""선택한 프로젝트를 Anaconda 프롬프트(새 창 또는 새 탭)로 띄운다."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import envs, store
|
|
10
|
+
|
|
11
|
+
RUN_COMMANDS = {
|
|
12
|
+
"none": "",
|
|
13
|
+
"claude": "claude",
|
|
14
|
+
"continue": "claude --continue",
|
|
15
|
+
"resume": "claude --resume",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_NO_WINDOW = 0x08000000 # CREATE_NO_WINDOW
|
|
19
|
+
|
|
20
|
+
# 자식 창에 물려주면 안 되는 변수들. ccdock 자체를 어떤 가상환경 안에서
|
|
21
|
+
# 실행했더라도 새 창은 깨끗한 상태에서 시작해야 한다.
|
|
22
|
+
_STRIP_VARS = (
|
|
23
|
+
"VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT", "CONDA_PREFIX", "CONDA_DEFAULT_ENV",
|
|
24
|
+
"CONDA_PROMPT_MODIFIER", "CONDA_SHLVL", "CONDA_PREFIX_1",
|
|
25
|
+
"PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clean_env():
|
|
30
|
+
"""부모 프로세스의 가상환경 흔적을 제거한 환경 변수 사본을 만든다."""
|
|
31
|
+
e = dict(os.environ)
|
|
32
|
+
roots = []
|
|
33
|
+
for var in ("VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_PREFIX_1"):
|
|
34
|
+
v = e.get(var)
|
|
35
|
+
if v:
|
|
36
|
+
roots.append(os.path.normcase(os.path.normpath(v)))
|
|
37
|
+
for var in _STRIP_VARS:
|
|
38
|
+
e.pop(var, None)
|
|
39
|
+
|
|
40
|
+
if roots:
|
|
41
|
+
kept = []
|
|
42
|
+
for part in (e.get("PATH") or "").split(os.pathsep):
|
|
43
|
+
if not part:
|
|
44
|
+
continue
|
|
45
|
+
norm = os.path.normcase(os.path.normpath(part))
|
|
46
|
+
if any(norm == r or norm.startswith(r + os.sep) for r in roots):
|
|
47
|
+
continue
|
|
48
|
+
kept.append(part)
|
|
49
|
+
e["PATH"] = os.pathsep.join(kept)
|
|
50
|
+
return e
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def wt_path():
|
|
55
|
+
return shutil.which("wt") or shutil.which("wt.exe")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def wt_running():
|
|
59
|
+
"""Windows Terminal 창이 실제로 떠 있는지 확인한다."""
|
|
60
|
+
try:
|
|
61
|
+
out = subprocess.run(
|
|
62
|
+
["tasklist", "/FI", "IMAGENAME eq WindowsTerminal.exe", "/NH"],
|
|
63
|
+
capture_output=True, text=True, timeout=6,
|
|
64
|
+
creationflags=_NO_WINDOW,
|
|
65
|
+
).stdout
|
|
66
|
+
except (OSError, subprocess.SubprocessError):
|
|
67
|
+
return False
|
|
68
|
+
return "WindowsTerminal.exe" in out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def list_terminal_windows():
|
|
72
|
+
"""열려 있는 Windows Terminal 창을 [{'hwnd':..,'title':..}] 로 돌려준다."""
|
|
73
|
+
if os.name != "nt":
|
|
74
|
+
return []
|
|
75
|
+
try:
|
|
76
|
+
import ctypes
|
|
77
|
+
from ctypes import wintypes
|
|
78
|
+
except ImportError:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
pids = set()
|
|
82
|
+
try:
|
|
83
|
+
out = subprocess.run(
|
|
84
|
+
["tasklist", "/FI", "IMAGENAME eq WindowsTerminal.exe", "/NH", "/FO", "CSV"],
|
|
85
|
+
capture_output=True, text=True, timeout=6, creationflags=_NO_WINDOW,
|
|
86
|
+
).stdout
|
|
87
|
+
for line in out.splitlines():
|
|
88
|
+
cols = [c.strip('"') for c in line.split('","')]
|
|
89
|
+
if len(cols) > 1 and cols[1].isdigit():
|
|
90
|
+
pids.add(int(cols[1]))
|
|
91
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
92
|
+
return []
|
|
93
|
+
if not pids:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
user32 = ctypes.windll.user32
|
|
97
|
+
found = []
|
|
98
|
+
WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
99
|
+
|
|
100
|
+
def cb(hwnd, _lparam):
|
|
101
|
+
if not user32.IsWindowVisible(hwnd):
|
|
102
|
+
return True
|
|
103
|
+
pid = wintypes.DWORD()
|
|
104
|
+
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
105
|
+
if pid.value not in pids:
|
|
106
|
+
return True
|
|
107
|
+
n = user32.GetWindowTextLengthW(hwnd)
|
|
108
|
+
if n <= 0:
|
|
109
|
+
return True
|
|
110
|
+
buf = ctypes.create_unicode_buffer(n + 1)
|
|
111
|
+
user32.GetWindowTextW(hwnd, buf, n + 1)
|
|
112
|
+
found.append({"hwnd": int(hwnd), "title": buf.value})
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
user32.EnumWindows(WNDENUMPROC(cb), 0)
|
|
117
|
+
except OSError:
|
|
118
|
+
return []
|
|
119
|
+
return found
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def focus_window(hwnd):
|
|
123
|
+
"""지정한 창을 앞으로 가져온다. wt -w 0 은 '가장 최근 창'을 뜻하므로,
|
|
124
|
+
탭을 붙이기 직전에 목표 창을 활성화해 두어야 한다."""
|
|
125
|
+
if os.name != "nt" or not hwnd:
|
|
126
|
+
return False
|
|
127
|
+
try:
|
|
128
|
+
import ctypes
|
|
129
|
+
except ImportError:
|
|
130
|
+
return False
|
|
131
|
+
user32 = ctypes.windll.user32
|
|
132
|
+
SW_RESTORE, SW_SHOW = 9, 5
|
|
133
|
+
try:
|
|
134
|
+
hwnd = int(hwnd)
|
|
135
|
+
if not user32.IsWindow(hwnd):
|
|
136
|
+
return False
|
|
137
|
+
user32.ShowWindow(hwnd, SW_RESTORE if user32.IsIconic(hwnd) else SW_SHOW)
|
|
138
|
+
try:
|
|
139
|
+
user32.SwitchToThisWindow(hwnd, True)
|
|
140
|
+
except (OSError, AttributeError):
|
|
141
|
+
pass
|
|
142
|
+
user32.SetForegroundWindow(hwnd)
|
|
143
|
+
time.sleep(0.35) # WT 가 MRU 창 정보를 갱신할 시간을 준다
|
|
144
|
+
return True
|
|
145
|
+
except (OSError, ValueError):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def terminal_state():
|
|
150
|
+
wt = wt_path()
|
|
151
|
+
windows = list_terminal_windows() if wt else []
|
|
152
|
+
return {
|
|
153
|
+
"wt_available": bool(wt),
|
|
154
|
+
"wt_running": bool(windows),
|
|
155
|
+
"windows": windows,
|
|
156
|
+
"conda_root": str(envs.conda_root() or ""),
|
|
157
|
+
"activate": envs.activate_bat() or "",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cleanup_tmp(keep_seconds=3600):
|
|
162
|
+
try:
|
|
163
|
+
now = time.time()
|
|
164
|
+
for f in store.TMP_DIR.glob("cc_*.bat"):
|
|
165
|
+
if now - f.stat().st_mtime > keep_seconds:
|
|
166
|
+
f.unlink(missing_ok=True)
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def env_label(env):
|
|
172
|
+
"""표시용 짧은 이름. venv 는 경로 대신 폴더 이름을 쓴다."""
|
|
173
|
+
if not env:
|
|
174
|
+
return "no-env"
|
|
175
|
+
return os.path.basename(str(env).rstrip("\\/")) if envs.is_venv_id(env) else str(env)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_script(path, env, run, title):
|
|
179
|
+
lines = ["@echo off", "chcp 65001 >nul"]
|
|
180
|
+
if title:
|
|
181
|
+
lines.append('title %s' % title.replace("%", "%%"))
|
|
182
|
+
lines.append('cd /d "%s"' % path)
|
|
183
|
+
if env:
|
|
184
|
+
if envs.is_venv_id(env):
|
|
185
|
+
act = envs.venv_activate(env)
|
|
186
|
+
lines.append('call "%s"' % act)
|
|
187
|
+
else:
|
|
188
|
+
activate = envs.activate_bat()
|
|
189
|
+
if activate:
|
|
190
|
+
lines.append('call "%s" %s' % (activate, env))
|
|
191
|
+
else:
|
|
192
|
+
lines.append("call conda activate %s" % env)
|
|
193
|
+
lines.append("if errorlevel 1 echo [ccdock] 가상환경 '%s' 활성화에 실패했습니다."
|
|
194
|
+
% env_label(env))
|
|
195
|
+
cmd = RUN_COMMANDS.get(run, "")
|
|
196
|
+
if cmd:
|
|
197
|
+
lines.append("echo [ccdock] %s" % cmd)
|
|
198
|
+
lines.append(cmd)
|
|
199
|
+
lines.append("")
|
|
200
|
+
|
|
201
|
+
store.TMP_DIR.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
script = store.TMP_DIR / ("cc_%d_%d.bat" % (int(time.time() * 1000), os.getpid()))
|
|
203
|
+
script.write_text("\r\n".join(lines), encoding="utf-8")
|
|
204
|
+
return script
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def launch(path, env="", run="claude", mode="auto", title=None, hwnd=None):
|
|
208
|
+
"""mode: 'tab'(기존 WT 창에 새 탭) | 'window'(새 창) | 'auto'.
|
|
209
|
+
|
|
210
|
+
hwnd 를 주면 그 Windows Terminal 창을 앞으로 가져온 뒤 탭을 붙인다.
|
|
211
|
+
(wt 의 -w 0 은 '가장 최근에 사용한 창'을 가리킨다.)
|
|
212
|
+
"""
|
|
213
|
+
if not os.path.isdir(path):
|
|
214
|
+
raise FileNotFoundError("폴더를 찾을 수 없습니다: %s" % path)
|
|
215
|
+
|
|
216
|
+
_cleanup_tmp()
|
|
217
|
+
title = title or os.path.basename(path.rstrip("\\/")) or path
|
|
218
|
+
label = "%s [%s]" % (title, env_label(env))
|
|
219
|
+
script = _write_script(path, env, run, label)
|
|
220
|
+
|
|
221
|
+
wt = wt_path()
|
|
222
|
+
windows = list_terminal_windows() if wt else []
|
|
223
|
+
if mode == "auto":
|
|
224
|
+
mode = "tab" if windows else "window"
|
|
225
|
+
if mode == "tab" and not wt:
|
|
226
|
+
mode = "window"
|
|
227
|
+
|
|
228
|
+
focused = False
|
|
229
|
+
if mode == "tab" and hwnd:
|
|
230
|
+
focused = focus_window(hwnd)
|
|
231
|
+
|
|
232
|
+
child_env = clean_env()
|
|
233
|
+
if wt and mode in ("tab", "window"):
|
|
234
|
+
if mode == "tab":
|
|
235
|
+
args = [wt, "-w", "0", "new-tab", "--title", label,
|
|
236
|
+
"-d", path, "cmd", "/k", str(script)]
|
|
237
|
+
else:
|
|
238
|
+
args = [wt, "-w", "new", "--title", label,
|
|
239
|
+
"-d", path, "cmd", "/k", str(script)]
|
|
240
|
+
subprocess.Popen(args, close_fds=True, env=child_env)
|
|
241
|
+
else:
|
|
242
|
+
mode = "window"
|
|
243
|
+
subprocess.Popen(
|
|
244
|
+
["cmd", "/c", "start", label, "cmd", "/k", str(script)],
|
|
245
|
+
cwd=path, close_fds=True, env=child_env,
|
|
246
|
+
)
|
|
247
|
+
return {"mode": mode, "script": str(script), "title": label, "focused": focused}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def launch_many(items, mode="window", stagger=0.7, hwnd=None):
|
|
251
|
+
"""워크스페이스 복원: 여러 프로젝트를 연달아 띄운다.
|
|
252
|
+
|
|
253
|
+
mode='tab' 이면 작업 중인 창을 건드리지 않도록 전용 창을 하나 새로 만들고,
|
|
254
|
+
나머지를 그 창에 탭으로 붙인다.
|
|
255
|
+
"""
|
|
256
|
+
results = []
|
|
257
|
+
for i, it in enumerate(items):
|
|
258
|
+
m = mode
|
|
259
|
+
target = hwnd
|
|
260
|
+
if mode == "tab" and i == 0:
|
|
261
|
+
m, target = "window", None # 전용 창을 먼저 만든다
|
|
262
|
+
if i > 0:
|
|
263
|
+
target = None # 방금 만든 창이 MRU 이므로 그대로 이어 붙는다
|
|
264
|
+
try:
|
|
265
|
+
results.append({"path": it.get("path"), "ok": True,
|
|
266
|
+
**launch(it.get("path"), it.get("env", ""),
|
|
267
|
+
it.get("run", "claude"), m, hwnd=target)})
|
|
268
|
+
except (OSError, FileNotFoundError) as e:
|
|
269
|
+
results.append({"path": it.get("path"), "ok": False, "error": str(e)})
|
|
270
|
+
time.sleep(stagger)
|
|
271
|
+
return results
|
ccdock/scanner.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Claude Code 사용 기록에서 프로젝트 목록을 수집한다.
|
|
2
|
+
|
|
3
|
+
데이터 출처
|
|
4
|
+
~/.claude.json -> projects 키에 실제 작업 경로가 그대로 들어 있다.
|
|
5
|
+
~/.claude/projects/<슬러그>/*.jsonl -> 파일 mtime 이 마지막 작업 시각.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import store
|
|
15
|
+
|
|
16
|
+
HOME = Path.home()
|
|
17
|
+
CLAUDE_JSON = HOME / ".claude.json"
|
|
18
|
+
SESSIONS_DIR = HOME / ".claude" / "projects"
|
|
19
|
+
|
|
20
|
+
_SLUG_RE = re.compile(r"[^a-zA-Z0-9]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def slug(path):
|
|
24
|
+
"""Claude Code 가 세션 폴더 이름을 만드는 규칙: 비영숫자 -> '-'."""
|
|
25
|
+
return _SLUG_RE.sub("-", str(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _session_mtimes():
|
|
29
|
+
"""세션 폴더 슬러그 -> (마지막 작업 시각, 세션 수)."""
|
|
30
|
+
out = {}
|
|
31
|
+
if not SESSIONS_DIR.is_dir():
|
|
32
|
+
return out
|
|
33
|
+
for d in SESSIONS_DIR.iterdir():
|
|
34
|
+
if not d.is_dir():
|
|
35
|
+
continue
|
|
36
|
+
newest, count = 0.0, 0
|
|
37
|
+
try:
|
|
38
|
+
for f in d.glob("*.jsonl"):
|
|
39
|
+
count += 1
|
|
40
|
+
m = f.stat().st_mtime
|
|
41
|
+
if m > newest:
|
|
42
|
+
newest = m
|
|
43
|
+
except OSError:
|
|
44
|
+
continue
|
|
45
|
+
if newest == 0.0:
|
|
46
|
+
try:
|
|
47
|
+
newest = d.stat().st_mtime
|
|
48
|
+
except OSError:
|
|
49
|
+
newest = 0.0
|
|
50
|
+
out[d.name] = (newest, count)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _claude_paths():
|
|
55
|
+
if not CLAUDE_JSON.exists():
|
|
56
|
+
return []
|
|
57
|
+
try:
|
|
58
|
+
with CLAUDE_JSON.open(encoding="utf-8") as f:
|
|
59
|
+
data = json.load(f)
|
|
60
|
+
except (ValueError, OSError):
|
|
61
|
+
return []
|
|
62
|
+
return list((data.get("projects") or {}).keys())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _guess_envs(path):
|
|
66
|
+
"""폴더 안의 힌트 파일로 가상환경 후보를 추측한다."""
|
|
67
|
+
hints = []
|
|
68
|
+
p = Path(path)
|
|
69
|
+
try:
|
|
70
|
+
# 프로젝트 안에 venv 가 있으면 가장 유력한 후보다.
|
|
71
|
+
for d in os.scandir(path):
|
|
72
|
+
if d.is_dir() and os.path.isfile(os.path.join(d.path, "pyvenv.cfg")):
|
|
73
|
+
hints.append(d.path)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
try:
|
|
77
|
+
for name in ("environment.yml", "environment.yaml", "conda.yaml"):
|
|
78
|
+
f = p / name
|
|
79
|
+
if f.is_file():
|
|
80
|
+
for line in f.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
81
|
+
if line.startswith("name:"):
|
|
82
|
+
hints.append(line.split(":", 1)[1].strip())
|
|
83
|
+
break
|
|
84
|
+
f = p / ".python-version"
|
|
85
|
+
if f.is_file():
|
|
86
|
+
hints.append(f.read_text(encoding="utf-8", errors="ignore").strip())
|
|
87
|
+
f = p / ".ccdock-env"
|
|
88
|
+
if f.is_file():
|
|
89
|
+
hints.insert(0, f.read_text(encoding="utf-8", errors="ignore").strip())
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
return [h for h in hints if h]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect(include_missing=False):
|
|
96
|
+
"""프로젝트 목록을 최근 작업순으로 돌려준다."""
|
|
97
|
+
cfg = store.load()
|
|
98
|
+
mtimes = _session_mtimes()
|
|
99
|
+
seen = {}
|
|
100
|
+
|
|
101
|
+
for raw in _claude_paths():
|
|
102
|
+
path = str(raw).replace("/", "\\").rstrip("\\")
|
|
103
|
+
if not path or len(path) < 3:
|
|
104
|
+
continue
|
|
105
|
+
k = store.key(path)
|
|
106
|
+
if k in seen:
|
|
107
|
+
continue
|
|
108
|
+
mtime, sessions = mtimes.get(slug(path), (0.0, 0))
|
|
109
|
+
exists = os.path.isdir(path)
|
|
110
|
+
entry = cfg["projects"].get(k, {})
|
|
111
|
+
if entry.get("hidden"):
|
|
112
|
+
continue
|
|
113
|
+
if not exists and not include_missing:
|
|
114
|
+
continue
|
|
115
|
+
seen[k] = {
|
|
116
|
+
"path": path,
|
|
117
|
+
"name": os.path.basename(path) or path,
|
|
118
|
+
"parent": os.path.dirname(path),
|
|
119
|
+
"mtime": mtime,
|
|
120
|
+
"sessions": sessions,
|
|
121
|
+
"exists": exists,
|
|
122
|
+
"env": entry.get("env") or "",
|
|
123
|
+
"pinned": bool(entry.get("pinned")),
|
|
124
|
+
"alias": entry.get("alias") or "",
|
|
125
|
+
"run": entry.get("run") or "",
|
|
126
|
+
"hints": _guess_envs(path) if exists and not entry.get("env") else [],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
items = list(seen.values())
|
|
130
|
+
items.sort(key=lambda x: (not x["pinned"], -x["mtime"]))
|
|
131
|
+
return items
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def relative_time(ts, now=None):
|
|
135
|
+
if not ts:
|
|
136
|
+
return "기록 없음"
|
|
137
|
+
now = now or time.time()
|
|
138
|
+
d = max(0, now - ts)
|
|
139
|
+
if d < 60:
|
|
140
|
+
return "방금 전"
|
|
141
|
+
if d < 3600:
|
|
142
|
+
return "%d분 전" % (d // 60)
|
|
143
|
+
if d < 86400:
|
|
144
|
+
return "%d시간 전" % (d // 3600)
|
|
145
|
+
if d < 86400 * 7:
|
|
146
|
+
return "%d일 전" % (d // 86400)
|
|
147
|
+
return time.strftime("%Y-%m-%d", time.localtime(ts))
|