claudechic 0.2.2__py3-none-any.whl → 0.3.1__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.
- claudechic/__init__.py +3 -1
- claudechic/__main__.py +12 -1
- claudechic/agent.py +60 -19
- claudechic/agent_manager.py +8 -2
- claudechic/analytics.py +62 -0
- claudechic/app.py +267 -158
- claudechic/commands.py +120 -6
- claudechic/config.py +80 -0
- claudechic/features/worktree/commands.py +70 -1
- claudechic/help_data.py +200 -0
- claudechic/messages.py +0 -17
- claudechic/processes.py +120 -0
- claudechic/profiling.py +18 -1
- claudechic/protocols.py +1 -1
- claudechic/remote.py +249 -0
- claudechic/sessions.py +60 -50
- claudechic/styles.tcss +19 -18
- claudechic/widgets/__init__.py +112 -41
- claudechic/widgets/base/__init__.py +20 -0
- claudechic/widgets/base/clickable.py +23 -0
- claudechic/widgets/base/copyable.py +55 -0
- claudechic/{cursor.py → widgets/base/cursor.py} +9 -28
- claudechic/widgets/base/tool_protocol.py +30 -0
- claudechic/widgets/content/__init__.py +41 -0
- claudechic/widgets/{diff.py → content/diff.py} +11 -65
- claudechic/widgets/{chat.py → content/message.py} +25 -76
- claudechic/widgets/{tools.py → content/tools.py} +12 -24
- claudechic/widgets/input/__init__.py +9 -0
- claudechic/widgets/layout/__init__.py +51 -0
- claudechic/widgets/{chat_view.py → layout/chat_view.py} +92 -43
- claudechic/widgets/{footer.py → layout/footer.py} +17 -7
- claudechic/widgets/{indicators.py → layout/indicators.py} +55 -7
- claudechic/widgets/layout/processes.py +68 -0
- claudechic/widgets/{agents.py → layout/sidebar.py} +163 -82
- claudechic/widgets/modals/__init__.py +9 -0
- claudechic/widgets/modals/process_modal.py +121 -0
- claudechic/widgets/{profile_modal.py → modals/profile.py} +2 -1
- claudechic/widgets/primitives/__init__.py +13 -0
- claudechic/widgets/{button.py → primitives/button.py} +1 -1
- claudechic/widgets/{collapsible.py → primitives/collapsible.py} +5 -1
- claudechic/widgets/{scroll.py → primitives/scroll.py} +2 -0
- claudechic/widgets/primitives/spinner.py +57 -0
- claudechic/widgets/prompts.py +146 -17
- claudechic/widgets/reports/__init__.py +10 -0
- claudechic-0.3.1.dist-info/METADATA +88 -0
- claudechic-0.3.1.dist-info/RECORD +71 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/WHEEL +1 -1
- claudechic-0.3.1.dist-info/licenses/LICENSE +21 -0
- claudechic/features/worktree/prompts.py +0 -101
- claudechic/widgets/model_prompt.py +0 -56
- claudechic-0.2.2.dist-info/METADATA +0 -58
- claudechic-0.2.2.dist-info/RECORD +0 -54
- /claudechic/widgets/{todo.py → content/todo.py} +0 -0
- /claudechic/widgets/{autocomplete.py → input/autocomplete.py} +0 -0
- /claudechic/widgets/{history_search.py → input/history_search.py} +0 -0
- /claudechic/widgets/{context_report.py → reports/context.py} +0 -0
- /claudechic/widgets/{usage.py → reports/usage.py} +0 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/entry_points.txt +0 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/top_level.txt +0 -0
claudechic/commands.py
CHANGED
|
@@ -54,8 +54,22 @@ def handle_command(app: "ChatApp", prompt: str) -> bool:
|
|
|
54
54
|
app._handle_usage_command()
|
|
55
55
|
return True
|
|
56
56
|
|
|
57
|
-
if cmd == "/model":
|
|
58
|
-
|
|
57
|
+
if cmd == "/model" or cmd.startswith("/model "):
|
|
58
|
+
parts = cmd.split(maxsplit=1)
|
|
59
|
+
if len(parts) == 1:
|
|
60
|
+
# No argument - show prompt
|
|
61
|
+
app._handle_model_prompt()
|
|
62
|
+
else:
|
|
63
|
+
# Direct model selection: /model sonnet
|
|
64
|
+
model = parts[1].lower()
|
|
65
|
+
valid_models = {"opus", "sonnet", "haiku"}
|
|
66
|
+
if model not in valid_models:
|
|
67
|
+
app.notify(
|
|
68
|
+
f"Invalid model '{model}'. Use: opus, sonnet, haiku",
|
|
69
|
+
severity="error",
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
app._set_agent_model(model)
|
|
59
73
|
return True
|
|
60
74
|
|
|
61
75
|
if cmd == "/exit":
|
|
@@ -65,6 +79,17 @@ def handle_command(app: "ChatApp", prompt: str) -> bool:
|
|
|
65
79
|
if cmd == "/welcome":
|
|
66
80
|
return _handle_welcome(app)
|
|
67
81
|
|
|
82
|
+
if cmd == "/help":
|
|
83
|
+
app.run_worker(_handle_help(app))
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if cmd == "/processes":
|
|
87
|
+
_handle_processes(app)
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
if cmd.startswith("/analytics"):
|
|
91
|
+
return _handle_analytics(app, cmd)
|
|
92
|
+
|
|
68
93
|
return False
|
|
69
94
|
|
|
70
95
|
|
|
@@ -91,7 +116,7 @@ def _handle_agent(app: "ChatApp", command: str) -> bool:
|
|
|
91
116
|
# In narrow mode, open the sidebar overlay instead of listing
|
|
92
117
|
width = app.size.width
|
|
93
118
|
has_content = (
|
|
94
|
-
len(app.agents) > 1 or app.
|
|
119
|
+
len(app.agents) > 1 or app.agent_section._worktrees or app.todo_panel.todos
|
|
95
120
|
)
|
|
96
121
|
if width < app.SIDEBAR_MIN_WIDTH and has_content:
|
|
97
122
|
app._sidebar_overlay_open = True
|
|
@@ -123,10 +148,37 @@ def _handle_agent(app: "ChatApp", command: str) -> bool:
|
|
|
123
148
|
app._close_agent(target)
|
|
124
149
|
return True
|
|
125
150
|
|
|
126
|
-
#
|
|
151
|
+
# Check if agent with this name exists - switch to it
|
|
127
152
|
name = subcommand
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
existing = app.agent_mgr.find_by_name(name) if app.agent_mgr else None
|
|
154
|
+
if existing:
|
|
155
|
+
app._switch_to_agent(existing.id)
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
# Create new agent - parse optional --model flag (supports --model=x or --model x)
|
|
159
|
+
cwd: Path | None = None
|
|
160
|
+
model = None
|
|
161
|
+
valid_models = {"opus", "sonnet", "haiku"}
|
|
162
|
+
args = parts[2:]
|
|
163
|
+
i = 0
|
|
164
|
+
while i < len(args):
|
|
165
|
+
part = args[i]
|
|
166
|
+
if part.startswith("--model="):
|
|
167
|
+
model = part[8:].lower()
|
|
168
|
+
elif part == "--model" and i + 1 < len(args):
|
|
169
|
+
model = args[i + 1].lower()
|
|
170
|
+
i += 1
|
|
171
|
+
elif not part.startswith("-") and cwd is None:
|
|
172
|
+
cwd = Path(part)
|
|
173
|
+
i += 1
|
|
174
|
+
if model and model not in valid_models:
|
|
175
|
+
app.notify(
|
|
176
|
+
f"Invalid model '{model}'. Use: opus, sonnet, haiku", severity="error"
|
|
177
|
+
)
|
|
178
|
+
return True
|
|
179
|
+
# Default to current agent's cwd, fallback to app's cwd
|
|
180
|
+
default_cwd = app._agent.cwd if app._agent else Path.cwd()
|
|
181
|
+
app._create_new_agent(name, cwd or default_cwd, model=model)
|
|
130
182
|
return True
|
|
131
183
|
|
|
132
184
|
|
|
@@ -239,6 +291,11 @@ Claude Chic is open source and written in Python with Textual. It's easy to ext
|
|
|
239
291
|
|
|
240
292
|
**Example:** Use simple quality of life features like shell support with `!ls`. or `!git diff`.
|
|
241
293
|
|
|
294
|
+
For more information, read
|
|
295
|
+
[the docs](https://matthewrocklin.com/claudechic),
|
|
296
|
+
[GitHub](https://github.com/mrocklin/claudechic),
|
|
297
|
+
or this [introductory video](https://www.youtube.com/watch?v=2HcORToX5sU).
|
|
298
|
+
|
|
242
299
|
Enjoy!
|
|
243
300
|
|
|
244
301
|
---
|
|
@@ -299,3 +356,60 @@ def _handle_compactish(app: "ChatApp", command: str) -> bool:
|
|
|
299
356
|
app.notify("Session compacted", timeout=3)
|
|
300
357
|
|
|
301
358
|
return True
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def _handle_help(app: "ChatApp") -> None:
|
|
362
|
+
"""Display help information."""
|
|
363
|
+
from claudechic.help_data import format_help
|
|
364
|
+
from claudechic.widgets import ChatMessage
|
|
365
|
+
|
|
366
|
+
agent = app._agent
|
|
367
|
+
help_text = await format_help(agent)
|
|
368
|
+
|
|
369
|
+
chat_view = app._chat_view
|
|
370
|
+
if chat_view:
|
|
371
|
+
msg = ChatMessage(help_text)
|
|
372
|
+
msg.add_class("system-message")
|
|
373
|
+
chat_view.mount(msg)
|
|
374
|
+
chat_view.scroll_if_tailing()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _handle_processes(app: "ChatApp") -> None:
|
|
378
|
+
"""Show process modal with current background processes."""
|
|
379
|
+
from claudechic.widgets.modals.process_modal import ProcessModal
|
|
380
|
+
|
|
381
|
+
agent = app._agent
|
|
382
|
+
if agent:
|
|
383
|
+
processes = agent.get_background_processes()
|
|
384
|
+
else:
|
|
385
|
+
processes = []
|
|
386
|
+
app.push_screen(ProcessModal(processes))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _handle_analytics(app: "ChatApp", command: str) -> bool:
|
|
390
|
+
"""Handle /analytics commands: opt-in, opt-out."""
|
|
391
|
+
from claudechic.config import (
|
|
392
|
+
get_analytics_enabled,
|
|
393
|
+
get_analytics_id,
|
|
394
|
+
set_analytics_enabled,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
parts = command.split()
|
|
398
|
+
subcommand = parts[1] if len(parts) > 1 else ""
|
|
399
|
+
|
|
400
|
+
if subcommand == "opt-in":
|
|
401
|
+
set_analytics_enabled(True)
|
|
402
|
+
app.notify("Analytics enabled")
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
if subcommand == "opt-out":
|
|
406
|
+
set_analytics_enabled(False)
|
|
407
|
+
app.notify("Analytics disabled")
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
# Show current status
|
|
411
|
+
enabled = get_analytics_enabled()
|
|
412
|
+
user_id = get_analytics_id()
|
|
413
|
+
status = "enabled" if enabled else "disabled"
|
|
414
|
+
app.notify(f"Analytics {status}, ID: {user_id[:8]}...")
|
|
415
|
+
return True
|
claudechic/config.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Configuration management for claudechic via ~/.claude/claudechic.yaml."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
CONFIG_PATH = Path.home() / ".claude" / "claudechic.yaml"
|
|
9
|
+
|
|
10
|
+
_config: dict = {}
|
|
11
|
+
_loaded: bool = False
|
|
12
|
+
_new_install: bool = False # True if analytics ID was just created
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_config() -> dict:
|
|
16
|
+
"""Load config from disk, creating with defaults if missing."""
|
|
17
|
+
global _config, _loaded, _new_install
|
|
18
|
+
if _loaded:
|
|
19
|
+
return _config
|
|
20
|
+
|
|
21
|
+
if CONFIG_PATH.exists():
|
|
22
|
+
with open(CONFIG_PATH) as f:
|
|
23
|
+
_config = yaml.safe_load(f) or {}
|
|
24
|
+
else:
|
|
25
|
+
_config = {}
|
|
26
|
+
|
|
27
|
+
# Ensure analytics section with defaults
|
|
28
|
+
if "analytics" not in _config:
|
|
29
|
+
_config["analytics"] = {}
|
|
30
|
+
if "enabled" not in _config["analytics"]:
|
|
31
|
+
_config["analytics"]["enabled"] = True
|
|
32
|
+
if "id" not in _config["analytics"]:
|
|
33
|
+
_config["analytics"]["id"] = str(uuid.uuid4())
|
|
34
|
+
_new_install = True
|
|
35
|
+
_save_config()
|
|
36
|
+
|
|
37
|
+
_loaded = True
|
|
38
|
+
return _config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_config() -> None:
|
|
42
|
+
"""Write config to disk."""
|
|
43
|
+
if not _config:
|
|
44
|
+
return
|
|
45
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with open(CONFIG_PATH, "w") as f:
|
|
47
|
+
yaml.dump(_config, f, default_flow_style=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_analytics_enabled() -> bool:
|
|
51
|
+
"""Check if analytics collection is enabled."""
|
|
52
|
+
return _load_config()["analytics"]["enabled"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_analytics_id() -> str:
|
|
56
|
+
"""Get the anonymous analytics ID, generating if needed."""
|
|
57
|
+
return _load_config()["analytics"]["id"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_analytics_enabled(enabled: bool) -> None:
|
|
61
|
+
"""Enable or disable analytics collection."""
|
|
62
|
+
_load_config()["analytics"]["enabled"] = enabled
|
|
63
|
+
_save_config()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_theme() -> str | None:
|
|
67
|
+
"""Get saved theme preference, or None if not set."""
|
|
68
|
+
return _load_config().get("theme")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def set_theme(theme: str) -> None:
|
|
72
|
+
"""Save theme preference."""
|
|
73
|
+
_load_config()["theme"] = theme
|
|
74
|
+
_save_config()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_new_install() -> bool:
|
|
78
|
+
"""Check if this is a new install (analytics ID was just created)."""
|
|
79
|
+
_load_config() # Ensure config is loaded
|
|
80
|
+
return _new_install
|
|
@@ -9,9 +9,11 @@ from textual.containers import Center
|
|
|
9
9
|
from textual import work
|
|
10
10
|
|
|
11
11
|
from claudechic.features.worktree.git import (
|
|
12
|
+
FinishInfo,
|
|
12
13
|
FinishPhase,
|
|
13
14
|
FinishState,
|
|
14
15
|
ResolutionAction,
|
|
16
|
+
WorktreeInfo,
|
|
15
17
|
WorktreeStatus,
|
|
16
18
|
clean_gitignored_files,
|
|
17
19
|
cleanup_worktrees,
|
|
@@ -27,7 +29,7 @@ from claudechic.features.worktree.git import (
|
|
|
27
29
|
remove_worktree,
|
|
28
30
|
start_worktree,
|
|
29
31
|
)
|
|
30
|
-
from claudechic.
|
|
32
|
+
from claudechic.widgets.prompts import (
|
|
31
33
|
UncommittedChangesPrompt,
|
|
32
34
|
WorktreePrompt,
|
|
33
35
|
)
|
|
@@ -59,6 +61,8 @@ def handle_worktree_command(app: "ChatApp", command: str) -> None:
|
|
|
59
61
|
elif subcommand == "cleanup":
|
|
60
62
|
branches = parts[2].split() if len(parts) > 2 else None
|
|
61
63
|
_handle_cleanup(app, branches)
|
|
64
|
+
elif subcommand == "discard":
|
|
65
|
+
_handle_discard(app)
|
|
62
66
|
else:
|
|
63
67
|
_switch_or_create_worktree(app, subcommand)
|
|
64
68
|
|
|
@@ -331,6 +335,71 @@ def _close_agents_for_branches(app: "ChatApp", branches: list[str]) -> None:
|
|
|
331
335
|
app._do_close_agent(agent.id)
|
|
332
336
|
|
|
333
337
|
|
|
338
|
+
def _handle_discard(app: "ChatApp") -> None:
|
|
339
|
+
"""Handle /worktree discard command - discard current worktree entirely."""
|
|
340
|
+
success, message, info = get_finish_info(app.sdk_cwd)
|
|
341
|
+
if not success or info is None:
|
|
342
|
+
app.notify(message, severity="error")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
status = diagnose_worktree(info)
|
|
346
|
+
|
|
347
|
+
# Check if there's anything to warn about
|
|
348
|
+
has_commits = status.commits_ahead > 0 and not status.is_merged
|
|
349
|
+
has_changes = status.has_uncommitted or status.untracked_other
|
|
350
|
+
|
|
351
|
+
if has_commits or has_changes:
|
|
352
|
+
_run_discard_prompt(app, info, status)
|
|
353
|
+
else:
|
|
354
|
+
_do_discard(app, info)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _do_discard(app: "ChatApp", info: FinishInfo) -> None:
|
|
358
|
+
"""Force remove worktree and branch."""
|
|
359
|
+
wt = WorktreeInfo(path=info.worktree_dir, branch=info.branch_name, is_main=False)
|
|
360
|
+
success, msg = remove_worktree(wt, force=True)
|
|
361
|
+
if success:
|
|
362
|
+
app.notify(f"Discarded {info.branch_name}")
|
|
363
|
+
_close_agents_for_branches(app, [info.branch_name])
|
|
364
|
+
else:
|
|
365
|
+
app.notify(msg, severity="error")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@work(group="discard_prompt", exclusive=True, exit_on_error=False)
|
|
369
|
+
async def _run_discard_prompt(
|
|
370
|
+
app: "ChatApp", info: FinishInfo, status: WorktreeStatus
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Prompt user to confirm discarding a worktree with commits/changes."""
|
|
373
|
+
from claudechic.widgets import SelectionPrompt, ChatInput
|
|
374
|
+
|
|
375
|
+
warnings = []
|
|
376
|
+
if status.commits_ahead > 0 and not status.is_merged:
|
|
377
|
+
warnings.append(f"{status.commits_ahead} unmerged commits")
|
|
378
|
+
if status.has_uncommitted:
|
|
379
|
+
warnings.append(f"{len(status.uncommitted_files)} uncommitted changes")
|
|
380
|
+
if status.untracked_other:
|
|
381
|
+
warnings.append(f"{len(status.untracked_other)} untracked files")
|
|
382
|
+
|
|
383
|
+
warning_text = ", ".join(warnings)
|
|
384
|
+
options = [
|
|
385
|
+
("discard", f"Discard anyway ({warning_text})"),
|
|
386
|
+
("cancel", "Cancel"),
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
async with app._show_prompt(
|
|
390
|
+
SelectionPrompt(f"Discard {info.branch_name}?", options)
|
|
391
|
+
) as prompt:
|
|
392
|
+
prompt.focus()
|
|
393
|
+
selected = await prompt.wait()
|
|
394
|
+
|
|
395
|
+
app.query_one("#input", ChatInput).focus()
|
|
396
|
+
|
|
397
|
+
if selected == "discard":
|
|
398
|
+
_do_discard(app, info)
|
|
399
|
+
else:
|
|
400
|
+
app.notify("Discard cancelled")
|
|
401
|
+
|
|
402
|
+
|
|
334
403
|
def _handle_cleanup(app: "ChatApp", branches: list[str] | None) -> None:
|
|
335
404
|
"""Handle /worktree cleanup command."""
|
|
336
405
|
results = cleanup_worktrees(branches)
|
claudechic/help_data.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Help data and formatting for /help command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from claudechic.agent import Agent
|
|
11
|
+
|
|
12
|
+
# Claudechic-specific commands
|
|
13
|
+
CHIC_COMMANDS = [
|
|
14
|
+
("/clear", "Clear chat and start new session"),
|
|
15
|
+
("/resume [id]", "Resume a previous session"),
|
|
16
|
+
("/agent [name] [path]", "Create or list agents"),
|
|
17
|
+
("/shell <cmd>", "Run shell command (or -i for interactive)"),
|
|
18
|
+
("/worktree <name>", "Create git worktree with agent"),
|
|
19
|
+
("/compactish [-n]", "Compact session to reduce context"),
|
|
20
|
+
("/usage", "Show API rate limit usage"),
|
|
21
|
+
("/model", "Change model"),
|
|
22
|
+
("/theme", "Search themes"),
|
|
23
|
+
("/welcome", "Show welcome message"),
|
|
24
|
+
("/help", "Show this help"),
|
|
25
|
+
("/exit", "Quit"),
|
|
26
|
+
("!<cmd>", "Shell command alias"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Keyboard shortcuts
|
|
30
|
+
SHORTCUTS = [
|
|
31
|
+
("Ctrl+C (x2)", "Quit"),
|
|
32
|
+
("Ctrl+L", "Clear chat"),
|
|
33
|
+
("Ctrl+S", "Screenshot"),
|
|
34
|
+
("Shift+Tab", "Toggle auto-edit mode"),
|
|
35
|
+
("Escape", "Cancel current action"),
|
|
36
|
+
("Ctrl+N", "New agent hint"),
|
|
37
|
+
("Ctrl+R", "History search"),
|
|
38
|
+
("Ctrl+1-9", "Switch to agent by position"),
|
|
39
|
+
("Enter", "Send message"),
|
|
40
|
+
("Ctrl+J", "Insert newline"),
|
|
41
|
+
("Up/Down", "Navigate input history"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# MCP tools from claudechic (mcp.py)
|
|
45
|
+
MCP_TOOLS = [
|
|
46
|
+
("spawn_agent", "Create new Claude agent"),
|
|
47
|
+
("spawn_worktree", "Create git worktree with agent"),
|
|
48
|
+
("ask_agent", "Send question to another agent"),
|
|
49
|
+
("tell_agent", "Send message without expecting reply"),
|
|
50
|
+
("list_agents", "List all running agents"),
|
|
51
|
+
("close_agent", "Close an agent by name"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_skill_description(path: Path) -> str:
|
|
56
|
+
"""Extract description from SKILL.md frontmatter."""
|
|
57
|
+
try:
|
|
58
|
+
content = path.read_text()
|
|
59
|
+
if content.startswith("---"):
|
|
60
|
+
end = content.find("---", 3)
|
|
61
|
+
if end > 0:
|
|
62
|
+
frontmatter = content[3:end]
|
|
63
|
+
for line in frontmatter.split("\n"):
|
|
64
|
+
if line.startswith("description:"):
|
|
65
|
+
return line.split(":", 1)[1].strip()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return "No description"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def discover_skills() -> list[tuple[str, str]]:
|
|
72
|
+
"""Discover enabled skills from plugins."""
|
|
73
|
+
skills = []
|
|
74
|
+
|
|
75
|
+
# Read settings for enabled plugins
|
|
76
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
77
|
+
if not settings_path.exists():
|
|
78
|
+
return skills
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
settings = json.loads(settings_path.read_text())
|
|
82
|
+
except Exception:
|
|
83
|
+
return skills
|
|
84
|
+
|
|
85
|
+
enabled = settings.get("enabledPlugins", {})
|
|
86
|
+
|
|
87
|
+
# Read installed plugins
|
|
88
|
+
installed_path = Path.home() / ".claude" / "plugins" / "installed_plugins.json"
|
|
89
|
+
if not installed_path.exists():
|
|
90
|
+
return skills
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
installed = json.loads(installed_path.read_text())
|
|
94
|
+
except Exception:
|
|
95
|
+
return skills
|
|
96
|
+
|
|
97
|
+
for plugin_id, is_enabled in enabled.items():
|
|
98
|
+
if not is_enabled:
|
|
99
|
+
continue
|
|
100
|
+
if plugin_id not in installed.get("plugins", {}):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
installs = installed["plugins"][plugin_id]
|
|
104
|
+
if not installs:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
install_path = Path(installs[0]["installPath"])
|
|
108
|
+
skills_dir = install_path / "skills"
|
|
109
|
+
if not skills_dir.exists():
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
for skill_dir in skills_dir.iterdir():
|
|
113
|
+
if not skill_dir.is_dir():
|
|
114
|
+
continue
|
|
115
|
+
skill_md = skill_dir / "SKILL.md"
|
|
116
|
+
if skill_md.exists():
|
|
117
|
+
desc = _parse_skill_description(skill_md)
|
|
118
|
+
# Format: plugin:skill or just skill if plugin matches
|
|
119
|
+
plugin_name = plugin_id.split("@")[0]
|
|
120
|
+
skill_name = skill_dir.name
|
|
121
|
+
if plugin_name == skill_name:
|
|
122
|
+
skills.append((skill_name, desc))
|
|
123
|
+
else:
|
|
124
|
+
skills.append((f"{plugin_name}:{skill_name}", desc))
|
|
125
|
+
|
|
126
|
+
return skills
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def get_sdk_commands(agent: "Agent | None") -> list[tuple[str, str]]:
|
|
130
|
+
"""Get commands from SDK server info."""
|
|
131
|
+
if not agent or not agent.client:
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
info = await agent.client.get_server_info()
|
|
136
|
+
if not info:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
return [
|
|
140
|
+
(f"/{cmd['name']}", cmd.get("description", ""))
|
|
141
|
+
for cmd in info.get("commands", [])
|
|
142
|
+
]
|
|
143
|
+
except Exception:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def format_help(agent: "Agent | None") -> str:
|
|
148
|
+
"""Format complete help text as markdown."""
|
|
149
|
+
lines = ["# Help\n"]
|
|
150
|
+
|
|
151
|
+
# Discover skills first so we can filter them from SDK commands
|
|
152
|
+
skills = discover_skills()
|
|
153
|
+
skill_names = {name.split(":")[0] for name, _ in skills} # e.g. "frontend-design"
|
|
154
|
+
|
|
155
|
+
# SDK commands (filter out skills which may appear here too)
|
|
156
|
+
sdk_cmds = await get_sdk_commands(agent)
|
|
157
|
+
sdk_cmds = [
|
|
158
|
+
(cmd, desc) for cmd, desc in sdk_cmds if cmd.lstrip("/") not in skill_names
|
|
159
|
+
]
|
|
160
|
+
if sdk_cmds:
|
|
161
|
+
lines.append("## Claude Code Commands\n")
|
|
162
|
+
lines.append("| Command | Description |")
|
|
163
|
+
lines.append("|---------|-------------|")
|
|
164
|
+
for cmd, desc in sdk_cmds:
|
|
165
|
+
lines.append(f"| `{cmd}` | {desc} |")
|
|
166
|
+
lines.append("")
|
|
167
|
+
|
|
168
|
+
# Chic commands
|
|
169
|
+
lines.append("## Claudechic Commands\n")
|
|
170
|
+
lines.append("| Command | Description |")
|
|
171
|
+
lines.append("|---------|-------------|")
|
|
172
|
+
for cmd, desc in CHIC_COMMANDS:
|
|
173
|
+
lines.append(f"| `{cmd}` | {desc} |")
|
|
174
|
+
lines.append("")
|
|
175
|
+
|
|
176
|
+
# Skills (already discovered above for filtering)
|
|
177
|
+
if skills:
|
|
178
|
+
lines.append("## Skills\n")
|
|
179
|
+
lines.append("| Skill | Description |")
|
|
180
|
+
lines.append("|-------|-------------|")
|
|
181
|
+
for name, desc in skills:
|
|
182
|
+
lines.append(f"| `/{name}` | {desc} |")
|
|
183
|
+
lines.append("")
|
|
184
|
+
|
|
185
|
+
# MCP tools
|
|
186
|
+
lines.append("## MCP Tools (chic)\n")
|
|
187
|
+
lines.append("| Tool | Description |")
|
|
188
|
+
lines.append("|------|-------------|")
|
|
189
|
+
for name, desc in MCP_TOOLS:
|
|
190
|
+
lines.append(f"| `{name}` | {desc} |")
|
|
191
|
+
lines.append("")
|
|
192
|
+
|
|
193
|
+
# Shortcuts
|
|
194
|
+
lines.append("## Keyboard Shortcuts\n")
|
|
195
|
+
lines.append("| Key | Action |")
|
|
196
|
+
lines.append("|-----|--------|")
|
|
197
|
+
for key, action in SHORTCUTS:
|
|
198
|
+
lines.append(f"| `{key}` | {action} |")
|
|
199
|
+
|
|
200
|
+
return "\n".join(lines)
|
claudechic/messages.py
CHANGED
|
@@ -10,23 +10,6 @@ from claude_agent_sdk import (
|
|
|
10
10
|
)
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
class StreamChunk(Message):
|
|
14
|
-
"""Message sent when a chunk of text is received from Claude."""
|
|
15
|
-
|
|
16
|
-
def __init__(
|
|
17
|
-
self,
|
|
18
|
-
text: str,
|
|
19
|
-
new_message: bool = False,
|
|
20
|
-
parent_tool_use_id: str | None = None,
|
|
21
|
-
agent_id: str | None = None,
|
|
22
|
-
) -> None:
|
|
23
|
-
self.text = text
|
|
24
|
-
self.new_message = new_message # Start a new ChatMessage widget
|
|
25
|
-
self.parent_tool_use_id = parent_tool_use_id # If set, belongs to a Task
|
|
26
|
-
self.agent_id = agent_id # Which agent this belongs to
|
|
27
|
-
super().__init__()
|
|
28
|
-
|
|
29
|
-
|
|
30
13
|
class ResponseComplete(Message):
|
|
31
14
|
"""Message sent when Claude's response is complete."""
|
|
32
15
|
|
claudechic/processes.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Background process tracking and detection for Claude agents."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class BackgroundProcess:
|
|
12
|
+
"""A background process being tracked."""
|
|
13
|
+
|
|
14
|
+
pid: int
|
|
15
|
+
command: str # Short description of the command
|
|
16
|
+
start_time: datetime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_command(cmdline: list[str]) -> str | None:
|
|
20
|
+
"""Extract the user command from a shell cmdline.
|
|
21
|
+
|
|
22
|
+
Claude wraps commands like:
|
|
23
|
+
['/bin/zsh', '-c', '-l', "source ... && eval 'sleep 30' ..."]
|
|
24
|
+
|
|
25
|
+
We want to extract just 'sleep 30'.
|
|
26
|
+
"""
|
|
27
|
+
# Find the argument containing the actual command (after -c and optional -l)
|
|
28
|
+
cmd_arg = None
|
|
29
|
+
for i, arg in enumerate(cmdline):
|
|
30
|
+
if arg == "-c" and i + 1 < len(cmdline):
|
|
31
|
+
# Next non-flag arg is the command
|
|
32
|
+
for j in range(i + 1, len(cmdline)):
|
|
33
|
+
if not cmdline[j].startswith("-"):
|
|
34
|
+
cmd_arg = cmdline[j]
|
|
35
|
+
break
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
if not cmd_arg:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
# Try to extract from eval '...' pattern
|
|
42
|
+
match = re.search(r"eval ['\"](.+?)['\"] \\< /dev/null", cmd_arg)
|
|
43
|
+
if match:
|
|
44
|
+
return match.group(1)
|
|
45
|
+
|
|
46
|
+
# Try simpler eval pattern
|
|
47
|
+
match = re.search(r"eval ['\"](.+?)['\"]", cmd_arg)
|
|
48
|
+
if match:
|
|
49
|
+
return match.group(1)
|
|
50
|
+
|
|
51
|
+
# Fall back to full command (truncated)
|
|
52
|
+
return cmd_arg[:50] if len(cmd_arg) > 50 else cmd_arg
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_child_processes(claude_pid: int) -> list[BackgroundProcess]:
|
|
56
|
+
"""Get background processes that are children of a claude process.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
claude_pid: PID of the claude binary for an agent
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of BackgroundProcess objects for active shell children
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
claude_proc = psutil.Process(claude_pid)
|
|
66
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
processes = []
|
|
70
|
+
for child in claude_proc.children(recursive=True):
|
|
71
|
+
try:
|
|
72
|
+
name = child.name()
|
|
73
|
+
# Only track shell processes (where commands run)
|
|
74
|
+
if name not in ("zsh", "bash", "sh"):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
status = child.status()
|
|
78
|
+
if status == psutil.STATUS_ZOMBIE:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Extract the command being run
|
|
82
|
+
cmdline = child.cmdline()
|
|
83
|
+
command = _extract_command(cmdline)
|
|
84
|
+
if not command:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Filter out our own monitoring commands
|
|
88
|
+
if command.startswith("ps "):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Get start time
|
|
92
|
+
create_time = datetime.fromtimestamp(child.create_time())
|
|
93
|
+
|
|
94
|
+
processes.append(
|
|
95
|
+
BackgroundProcess(
|
|
96
|
+
pid=child.pid, command=command, start_time=create_time
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
return processes
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_claude_pid_from_client(client) -> int | None:
|
|
106
|
+
"""Extract the claude process PID from an SDK client.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
client: ClaudeSDKClient instance
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
PID of the claude subprocess, or None if not available
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
transport = client._transport
|
|
116
|
+
if transport and hasattr(transport, "_process") and transport._process:
|
|
117
|
+
return transport._process.pid
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
return None
|