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 ADDED
@@ -0,0 +1,3 @@
1
+ """ccdock - Claude Code 프로젝트/가상환경 런처 대시보드."""
2
+
3
+ __version__ = "0.1.0"
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))