forge-cw 0.1.0

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/console/assets/addon-fit-BlxrFPDK.js +1 -0
  3. package/console/assets/addon-web-links-BWjmmSgS.js +12 -0
  4. package/console/assets/index-Digctq9W.css +32 -0
  5. package/console/assets/index-ItVUTuQf.js +31 -0
  6. package/console/assets/xterm-DlVFs1Kw.js +9 -0
  7. package/console/index.html +13 -0
  8. package/cw/.bundle.json +4 -0
  9. package/cw/agents/presentation-curator.md +119 -0
  10. package/cw/agents/weather-agent.md +67 -0
  11. package/cw/cw +6188 -0
  12. package/cw/cw-shell-integration.sh +132 -0
  13. package/cw/hooks/HOOKS-README.md +471 -0
  14. package/cw/hooks/config/hooks-config.json +24 -0
  15. package/cw/hooks/scripts/auto-compact.py +133 -0
  16. package/cw/hooks/scripts/hooks.py +472 -0
  17. package/cw/hooks/scripts/review-autoclose.py +171 -0
  18. package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.mp3 +0 -0
  19. package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.wav +0 -0
  20. package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.mp3 +0 -0
  21. package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.wav +0 -0
  22. package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.mp3 +0 -0
  23. package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.wav +0 -0
  24. package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.mp3 +0 -0
  25. package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.wav +0 -0
  26. package/cw/hooks/sounds/agent_stop/agent_stop.mp3 +0 -0
  27. package/cw/hooks/sounds/agent_stop/agent_stop.wav +0 -0
  28. package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.mp3 +0 -0
  29. package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.wav +0 -0
  30. package/cw/hooks/sounds/configchange/configchange.mp3 +0 -0
  31. package/cw/hooks/sounds/configchange/configchange.wav +0 -0
  32. package/cw/hooks/sounds/notification/notification.mp3 +0 -0
  33. package/cw/hooks/sounds/notification/notification.wav +0 -0
  34. package/cw/hooks/sounds/permissionrequest/permissionrequest.mp3 +0 -0
  35. package/cw/hooks/sounds/permissionrequest/permissionrequest.wav +0 -0
  36. package/cw/hooks/sounds/posttooluse/posttooluse.mp3 +0 -0
  37. package/cw/hooks/sounds/posttooluse/posttooluse.wav +0 -0
  38. package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.mp3 +0 -0
  39. package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.wav +0 -0
  40. package/cw/hooks/sounds/precompact/precompact.mp3 +0 -0
  41. package/cw/hooks/sounds/precompact/precompact.wav +0 -0
  42. package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.mp3 +0 -0
  43. package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.wav +0 -0
  44. package/cw/hooks/sounds/pretooluse/pretooluse.mp3 +0 -0
  45. package/cw/hooks/sounds/pretooluse/pretooluse.wav +0 -0
  46. package/cw/hooks/sounds/sessionend/sessionend.mp3 +0 -0
  47. package/cw/hooks/sounds/sessionend/sessionend.wav +0 -0
  48. package/cw/hooks/sounds/sessionstart/sessionstart.mp3 +0 -0
  49. package/cw/hooks/sounds/sessionstart/sessionstart.wav +0 -0
  50. package/cw/hooks/sounds/setup/Setup.mp3 +0 -0
  51. package/cw/hooks/sounds/setup/Setup.wav +0 -0
  52. package/cw/hooks/sounds/stop/stop.mp3 +0 -0
  53. package/cw/hooks/sounds/stop/stop.wav +0 -0
  54. package/cw/hooks/sounds/subagentstart/subagentstart.mp3 +0 -0
  55. package/cw/hooks/sounds/subagentstart/subagentstart.wav +0 -0
  56. package/cw/hooks/sounds/subagentstop/subagentstop.mp3 +0 -0
  57. package/cw/hooks/sounds/subagentstop/subagentstop.wav +0 -0
  58. package/cw/hooks/sounds/taskcompleted/taskcompleted.mp3 +0 -0
  59. package/cw/hooks/sounds/taskcompleted/taskcompleted.wav +0 -0
  60. package/cw/hooks/sounds/teammateidle/teammateidle.mp3 +0 -0
  61. package/cw/hooks/sounds/teammateidle/teammateidle.wav +0 -0
  62. package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.mp3 +0 -0
  63. package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.wav +0 -0
  64. package/cw/hooks/sounds/worktreecreate/worktreecreate.mp3 +0 -0
  65. package/cw/hooks/sounds/worktreecreate/worktreecreate.wav +0 -0
  66. package/cw/hooks/sounds/worktreeremove/worktreeremove.mp3 +0 -0
  67. package/cw/hooks/sounds/worktreeremove/worktreeremove.wav +0 -0
  68. package/cw/install.sh +243 -0
  69. package/cw/lib/context/github.sh +51 -0
  70. package/cw/lib/context/linear.sh +49 -0
  71. package/cw/lib/context/notion.sh +38 -0
  72. package/cw/lib/dashboard/activity-hook.py +134 -0
  73. package/cw/lib/dashboard/index.html +1515 -0
  74. package/cw/lib/dashboard/server.py +379 -0
  75. package/cw/lib/dashboard/start.sh +15 -0
  76. package/cw/lib/harnesses/claude.sh +92 -0
  77. package/cw/lib/harnesses/codex.sh +245 -0
  78. package/cw/lib/harnesses/opencode.sh +120 -0
  79. package/cw/lib/harnesses/pi.sh +66 -0
  80. package/cw/lib/iterm2.sh +140 -0
  81. package/cw/templates/CLAUDE.template.md +48 -0
  82. package/dist/index.js +4376 -0
  83. package/package.json +51 -0
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env python3
2
+ """CW Retro Dashboard — Data server.
3
+
4
+ Tiny HTTP server that:
5
+ GET / → serves index.html
6
+ GET /api/data → returns live CW workspace data as JSON
7
+ GET /api/activity?after=<ts> → returns recent activity events
8
+ GET /api/stream → SSE stream of real-time activity events
9
+ """
10
+
11
+ import http.server
12
+ import json
13
+ import os
14
+ import glob
15
+ import sys
16
+ import webbrowser
17
+ import socket
18
+ import time
19
+ import threading
20
+ from pathlib import Path
21
+ from datetime import datetime, timezone
22
+ from urllib.parse import urlparse, parse_qs
23
+
24
+ CW_HOME = os.environ.get("CW_HOME", os.path.expanduser("~/.cw"))
25
+ DASHBOARD_DIR = os.path.dirname(os.path.abspath(__file__))
26
+ ACTIVITY_FILE = os.path.join(CW_HOME, "activity.jsonl")
27
+
28
+ # per-harness marker files that indicate a logged-in credential dir
29
+ HARNESS_AUTH_MARKERS = {
30
+ "claude": [".claude.json"],
31
+ "codex": ["auth.json", "env"],
32
+ "pi": ["auth.json", "env"],
33
+ "opencode": ["auth.json", "env"],
34
+ }
35
+
36
+
37
+ def harness_dir(account_path, harness):
38
+ """claude keeps the flat dir unless a claude subdir exists"""
39
+ sub = os.path.join(account_path, "claude")
40
+ if harness == "claude" and not os.path.isdir(sub):
41
+ return account_path
42
+ return os.path.join(account_path, harness)
43
+
44
+
45
+ def account_harness(account_path):
46
+ """the account's default harness, mirroring _account_default_harness"""
47
+ meta_file = os.path.join(account_path, "meta.json")
48
+ if os.path.isfile(meta_file):
49
+ try:
50
+ with open(meta_file) as f:
51
+ h = json.load(f).get("harness")
52
+ if h:
53
+ return h
54
+ except Exception:
55
+ pass
56
+ return "claude"
57
+
58
+
59
+ def harness_authenticated(account_path, harness):
60
+ hdir = harness_dir(account_path, harness)
61
+ markers = HARNESS_AUTH_MARKERS.get(harness, [])
62
+ return any(os.path.isfile(os.path.join(hdir, m)) for m in markers)
63
+
64
+
65
+ def collect_data():
66
+ data = {
67
+ "version": "0.1.0",
68
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
69
+ "accounts": [],
70
+ "projects": [],
71
+ "sessions": [],
72
+ "stats": {},
73
+ "recent_log": [],
74
+ }
75
+
76
+ # ── Accounts ──────────────────────────────────────────────
77
+ accounts_dir = os.path.join(CW_HOME, "accounts")
78
+ if os.path.isdir(accounts_dir):
79
+ for name in sorted(os.listdir(accounts_dir)):
80
+ acct_path = os.path.join(accounts_dir, name)
81
+ if not os.path.isdir(acct_path) or name.startswith("."):
82
+ continue
83
+ harness = account_harness(acct_path)
84
+ data["accounts"].append(
85
+ {
86
+ "name": name,
87
+ "harness": harness,
88
+ "authenticated": harness_authenticated(acct_path, harness),
89
+ }
90
+ )
91
+
92
+ # ── Projects ──────────────────────────────────────────────
93
+ registry = os.path.join(CW_HOME, "projects.json")
94
+ if os.path.isfile(registry):
95
+ with open(registry) as f:
96
+ projects = json.load(f)
97
+ for name, info in projects.items():
98
+ pp = info.get("path", "")
99
+ claude_md = os.path.isfile(os.path.join(pp, "CLAUDE.md"))
100
+ agents_dir = os.path.join(pp, ".claude", "agents")
101
+ commands_dir = os.path.join(pp, ".claude", "commands")
102
+ agents = (
103
+ len(glob.glob(os.path.join(agents_dir, "*.md")))
104
+ if os.path.isdir(agents_dir)
105
+ else 0
106
+ )
107
+ commands = (
108
+ len(glob.glob(os.path.join(commands_dir, "*.md")))
109
+ if os.path.isdir(commands_dir)
110
+ else 0
111
+ )
112
+ has_mcp = False
113
+ settings_file = os.path.join(pp, ".claude", "settings.json")
114
+ if os.path.isfile(settings_file):
115
+ try:
116
+ with open(settings_file) as sf:
117
+ s = json.load(sf)
118
+ if s.get("mcpServers") or s.get("mcp_servers"):
119
+ has_mcp = True
120
+ except Exception:
121
+ pass
122
+
123
+ data["projects"].append(
124
+ {
125
+ "name": name,
126
+ "account": info.get("account", ""),
127
+ "path": pp,
128
+ "type": info.get("type", ""),
129
+ "has_claude_md": claude_md,
130
+ "agents": agents,
131
+ "commands": commands,
132
+ "has_mcp": has_mcp,
133
+ "git": os.path.isdir(os.path.join(pp, ".git")),
134
+ }
135
+ )
136
+
137
+ # ── Sessions ──────────────────────────────────────────────
138
+ sessions_dir = os.path.join(CW_HOME, "sessions")
139
+ if os.path.isdir(sessions_dir):
140
+ for project in sorted(os.listdir(sessions_dir)):
141
+ project_dir = os.path.join(sessions_dir, project)
142
+ if not os.path.isdir(project_dir):
143
+ continue
144
+ for session in sorted(os.listdir(project_dir)):
145
+ meta_file = os.path.join(project_dir, session, "session.json")
146
+ if os.path.isfile(meta_file):
147
+ try:
148
+ with open(meta_file) as f:
149
+ meta = json.load(f)
150
+ meta["harness"] = meta.get("harness") or "claude"
151
+ meta["provider"] = meta.get("provider") or "native"
152
+ data["sessions"].append(meta)
153
+ except Exception:
154
+ pass
155
+
156
+ # ── Recent log ────────────────────────────────────────────
157
+ log_file = os.path.join(CW_HOME, "sessions.log")
158
+ if os.path.isfile(log_file):
159
+ with open(log_file) as f:
160
+ lines = f.readlines()
161
+ data["recent_log"] = [l.strip() for l in lines[-30:] if l.strip()]
162
+
163
+ # ── Stats ─────────────────────────────────────────────────
164
+ active = [s for s in data["sessions"] if s.get("status") == "active"]
165
+ done = [s for s in data["sessions"] if s.get("status") == "done"]
166
+ tasks = [s for s in active if s.get("type") == "task"]
167
+ reviews = [s for s in active if s.get("type") == "review"]
168
+ total_opens = sum(s.get("opens", 0) for s in data["sessions"])
169
+
170
+ data["stats"] = {
171
+ "total_sessions": len(data["sessions"]),
172
+ "active_sessions": len(active),
173
+ "completed_sessions": len(done),
174
+ "active_tasks": len(tasks),
175
+ "active_reviews": len(reviews),
176
+ "total_opens": total_opens,
177
+ "total_projects": len(data["projects"]),
178
+ "total_accounts": len(data["accounts"]),
179
+ }
180
+
181
+ return data
182
+
183
+
184
+ def collect_activity(after_ts=0.0, limit=100):
185
+ """Read recent activity events from the JSONL file."""
186
+ events = []
187
+ if not os.path.isfile(ACTIVITY_FILE):
188
+ return events
189
+
190
+ try:
191
+ with open(ACTIVITY_FILE) as f:
192
+ for line in f:
193
+ line = line.strip()
194
+ if not line:
195
+ continue
196
+ try:
197
+ entry = json.loads(line)
198
+ if entry.get("ts", 0) > after_ts:
199
+ events.append(entry)
200
+ except Exception:
201
+ continue
202
+ except Exception:
203
+ pass
204
+
205
+ # Return most recent entries
206
+ return events[-limit:]
207
+
208
+
209
+ def get_live_sessions(events, window=30):
210
+ """Derive which sessions are 'live' (had activity in last N seconds)."""
211
+ now = time.time()
212
+ cutoff = now - window
213
+ live = {}
214
+
215
+ for e in events:
216
+ ts = e.get("ts", 0)
217
+ if ts < cutoff:
218
+ continue
219
+ key = e.get("project", "") + "/" + e.get("task", "")
220
+ if not key or key == "/":
221
+ key = e.get("session", "unknown")
222
+
223
+ existing = live.get(key)
224
+ if not existing or ts > existing["ts"]:
225
+ live[key] = {
226
+ "project": e.get("project", ""),
227
+ "task": e.get("task", ""),
228
+ "type": e.get("type", ""),
229
+ "account": e.get("account", ""),
230
+ "session": e.get("session", ""),
231
+ "event": e.get("event", ""),
232
+ "tool": e.get("tool", ""),
233
+ "summary": e.get("summary", ""),
234
+ "ts": ts,
235
+ "age": round(now - ts, 1),
236
+ }
237
+
238
+ return list(live.values())
239
+
240
+
241
+ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
242
+ def __init__(self, *args, **kwargs):
243
+ super().__init__(*args, directory=DASHBOARD_DIR, **kwargs)
244
+
245
+ def do_GET(self):
246
+ parsed = urlparse(self.path)
247
+
248
+ if parsed.path == "/api/data":
249
+ self._json_response(collect_data())
250
+
251
+ elif parsed.path == "/api/activity":
252
+ qs = parse_qs(parsed.query)
253
+ after = float(qs.get("after", ["0"])[0])
254
+ limit = int(qs.get("limit", ["100"])[0])
255
+ events = collect_activity(after, limit)
256
+ live = get_live_sessions(events)
257
+ self._json_response({"events": events, "live": live})
258
+
259
+ elif parsed.path == "/api/stream":
260
+ self._sse_stream()
261
+
262
+ elif parsed.path == "/" or parsed.path == "/index.html":
263
+ self.send_response(200)
264
+ self.send_header("Content-Type", "text/html; charset=utf-8")
265
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
266
+ self.send_header("Pragma", "no-cache")
267
+ self.end_headers()
268
+ html_path = os.path.join(DASHBOARD_DIR, "index.html")
269
+ with open(html_path, "rb") as f:
270
+ self.wfile.write(f.read())
271
+ else:
272
+ super().do_GET()
273
+
274
+ def _json_response(self, data):
275
+ self.send_response(200)
276
+ self.send_header("Content-Type", "application/json")
277
+ self.send_header("Access-Control-Allow-Origin", "*")
278
+ self.end_headers()
279
+ self.wfile.write(json.dumps(data).encode())
280
+
281
+ def _sse_stream(self):
282
+ """Server-Sent Events: tail activity.jsonl and push new lines."""
283
+ self.send_response(200)
284
+ self.send_header("Content-Type", "text/event-stream")
285
+ self.send_header("Cache-Control", "no-cache")
286
+ self.send_header("Access-Control-Allow-Origin", "*")
287
+ self.send_header("Connection", "keep-alive")
288
+ self.end_headers()
289
+
290
+ # Seek to end of file
291
+ try:
292
+ if os.path.isfile(ACTIVITY_FILE):
293
+ f = open(ACTIVITY_FILE, "r")
294
+ f.seek(0, 2) # seek to end
295
+ else:
296
+ f = None
297
+
298
+ last_size = os.path.getsize(ACTIVITY_FILE) if f else 0
299
+
300
+ while True:
301
+ # Check if file was recreated/truncated
302
+ try:
303
+ cur_size = os.path.getsize(ACTIVITY_FILE) if os.path.isfile(ACTIVITY_FILE) else 0
304
+ except OSError:
305
+ cur_size = 0
306
+
307
+ if f is None and os.path.isfile(ACTIVITY_FILE):
308
+ f = open(ACTIVITY_FILE, "r")
309
+ f.seek(0, 2)
310
+ last_size = cur_size
311
+ elif f and cur_size < last_size:
312
+ # File was truncated/rotated, reopen
313
+ f.close()
314
+ f = open(ACTIVITY_FILE, "r")
315
+ last_size = 0
316
+
317
+ if f:
318
+ new_lines = f.readlines()
319
+ last_size = cur_size
320
+ for line in new_lines:
321
+ line = line.strip()
322
+ if not line:
323
+ continue
324
+ try:
325
+ entry = json.loads(line)
326
+ # Send as SSE event
327
+ payload = json.dumps(entry, ensure_ascii=False)
328
+ self.wfile.write(f"data: {payload}\n\n".encode())
329
+ self.wfile.flush()
330
+ except Exception:
331
+ continue
332
+
333
+ # Send keepalive comment every cycle
334
+ try:
335
+ self.wfile.write(f": keepalive {time.time()}\n\n".encode())
336
+ self.wfile.flush()
337
+ except (BrokenPipeError, ConnectionResetError):
338
+ break
339
+
340
+ time.sleep(0.5)
341
+
342
+ except (BrokenPipeError, ConnectionResetError):
343
+ pass
344
+ finally:
345
+ if f:
346
+ f.close()
347
+
348
+ def log_message(self, format, *args):
349
+ pass
350
+
351
+
352
+ def find_free_port(start=3737):
353
+ for port in range(start, start + 100):
354
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
355
+ try:
356
+ s.bind(("127.0.0.1", port))
357
+ return port
358
+ except OSError:
359
+ continue
360
+ return start
361
+
362
+
363
+ def main():
364
+ port = find_free_port()
365
+ # Use ThreadingHTTPServer so SSE doesn't block other requests
366
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), DashboardHandler)
367
+ url = f"http://127.0.0.1:{port}"
368
+ print(f"\033[0;32m[cw]\033[0m Retro Dashboard \u2192 {url}")
369
+ print(f"\033[2m Press Ctrl+C to stop\033[0m")
370
+ webbrowser.open(url)
371
+ try:
372
+ server.serve_forever()
373
+ except KeyboardInterrupt:
374
+ print(f"\n\033[0;32m[cw]\033[0m Dashboard stopped")
375
+ server.server_close()
376
+
377
+
378
+ if __name__ == "__main__":
379
+ main()
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ # ── CW Retro Dashboard Launcher ──────────────────────────────
3
+ # Usage: bash lib/dashboard/start.sh
4
+ # Or via cw: cw arcade
5
+ set -uo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+
9
+ G='\033[0;32m' C='\033[0;36m' DIM='\033[2m' NC='\033[0m'
10
+
11
+ echo -e "${G}[cw]${NC} ${C}Launching CW Station...${NC}"
12
+ echo -e "${DIM} Retro Arcade Dashboard${NC}"
13
+ echo ""
14
+
15
+ python3 "$SCRIPT_DIR/server.py"
@@ -0,0 +1,92 @@
1
+ # claude code driver
2
+ # no custom_provider: the Anthropic-compatible endpoint path is not built, CW_PROVIDER is never read
3
+ claude_supports() {
4
+ local cap
5
+ for cap in resume_by_name continue_last non_interactive_prompt interactive_prompt \
6
+ mcp hooks skip_permissions agent_teams plugins \
7
+ model_flag statusline instructions_file skills slash_commands; do
8
+ [[ "$1" == "$cap" ]] && return 0
9
+ done
10
+ return 1
11
+ }
12
+
13
+ claude_config_env() {
14
+ printf 'CLAUDE_CONFIG_DIR=%s\n' "$CW_HARNESS_DIR"
15
+ }
16
+
17
+ claude_session_ref() {
18
+ printf '%s' ""
19
+ }
20
+
21
+ # builds argv for a plugin subcommand without spawning it
22
+ claude_plugin() {
23
+ local op="${1:-}" plugin="${2:-}"
24
+ _claude_base_env
25
+ case "$op" in
26
+ list) HARNESS_ARGV=(claude plugin list) ;;
27
+ add) HARNESS_ARGV=(claude plugin add "$plugin") ;;
28
+ *) return 1 ;;
29
+ esac
30
+ return 0
31
+ }
32
+
33
+ # builds the common prefix shared by launch and resume
34
+ _claude_base_argv() {
35
+ HARNESS_ARGV=(claude)
36
+ local f
37
+ for f in $CW_EXTRA_FLAGS; do HARNESS_ARGV+=("$f"); done
38
+ [[ -n "$CW_MODEL" ]] && HARNESS_ARGV+=(--model "$CW_MODEL")
39
+ return 0
40
+ }
41
+
42
+ _claude_base_env() {
43
+ HARNESS_ENV=("CLAUDE_CONFIG_DIR=$CW_HARNESS_DIR")
44
+ [[ -n "${CW_TEAM_ENV:-}" ]] && HARNESS_ENV+=("$CW_TEAM_ENV")
45
+ return 0
46
+ }
47
+
48
+ claude_launch() {
49
+ _claude_base_env
50
+ if [[ -n "${CW_PASSTHRU_ARGV+x}" ]]; then
51
+ HARNESS_ARGV=(claude ${CW_PASSTHRU_ARGV[@]+"${CW_PASSTHRU_ARGV[@]}"})
52
+ return 0
53
+ fi
54
+ _claude_base_argv
55
+ [[ -n "$CW_SESSION_NAME" ]] && HARNESS_ARGV+=(--name "$CW_SESSION_NAME")
56
+ [[ -n "$CW_PROMPT" ]] && HARNESS_ARGV+=("$CW_PROMPT")
57
+ return 0
58
+ }
59
+
60
+ # three attempts: resume by name, continue last, start named
61
+ claude_resume() {
62
+ local attempt="$1"
63
+ _claude_base_env
64
+ _claude_base_argv
65
+ case "$attempt" in
66
+ 1) HARNESS_ARGV+=(--resume "$CW_SESSION_NAME") ;;
67
+ 2) HARNESS_ARGV+=(--continue) ;;
68
+ 3) HARNESS_ARGV+=(--name "$CW_SESSION_NAME") ;;
69
+ *) return 1 ;;
70
+ esac
71
+ [[ -n "$CW_PROMPT" ]] && HARNESS_ARGV+=("$CW_PROMPT")
72
+ return 0
73
+ }
74
+
75
+ claude_doctor() {
76
+ local status detail="null"
77
+ if ! command -v claude >/dev/null 2>&1; then
78
+ status="not_installed"; detail='"claude not found on PATH"'
79
+ elif [[ -f "$CW_HARNESS_DIR/.claude.json" ]]; then
80
+ status="connected"
81
+ else
82
+ status="not_logged_in"
83
+ fi
84
+ printf '{"harness":"claude","status":"%s","detail":%s}\n' "$status" "$detail"
85
+ }
86
+
87
+ # unverified: no confirmed --no-browser/device-code flag or api-key import path for claude
88
+ claude_login() {
89
+ HARNESS_ENV=("CLAUDE_CONFIG_DIR=$CW_HARNESS_DIR")
90
+ HARNESS_ARGV=(claude /login)
91
+ return 0
92
+ }