Skip to content

Commit 99a0cab

Browse files
committed
docs(claude): update commands and add test instructions
1 parent 392f09a commit 99a0cab

8 files changed

Lines changed: 249 additions & 212 deletions

File tree

CLAUDE.md

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,67 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
55
## Commands
66

77
```bash
8-
pip install -e . # Install package in editable mode (dev)
9-
iclaw-login # Authenticate with GitHub via device flow (saves token to ~/.config/iclaw/config.json)
10-
iclaw # Start the interactive CLI REPL
11-
ruff check . # Lint code
12-
ruff format . # Format code
8+
pip install -e . # Install package in editable mode
9+
iclaw-login # Authenticate via GitHub device flow
10+
iclaw # Start the interactive CLI REPL
11+
ruff check . # Lint
12+
ruff format . # Format (also runs on pre-commit)
1313
```
1414

15-
Pre-commit hooks run automatically on commit: ruff format.
15+
### Running Tests
1616

17-
## Architecture
17+
```bash
18+
# Unit tests (no credentials needed)
19+
export PYTHONPATH=$PYTHONPATH:.
20+
python3 -m unittest discover tests
21+
22+
# Single test file
23+
python3 -m unittest tests/test_main.py
24+
25+
# Single test case
26+
python3 -m unittest tests/test_main.py::TestMain::test_main_chat
27+
28+
# With coverage
29+
pip install coverage
30+
python3 -m coverage run -m unittest discover tests
31+
python3 -m coverage report -m iclaw/*.py iclaw/commands/*.py iclaw/tools/*.py
32+
33+
# Integration tests (require GITHUB_TOKEN_INTEGRATION env var)
34+
GITHUB_TOKEN_INTEGRATION=<token> python3 -m unittest discover integration_tests
35+
```
1836

19-
This is a Python CLI package (iclaw) that provides an interactive terminal REPL for chatting with GitHub Copilot.
37+
### GitHub Actions
38+
39+
- **`.github/workflows/test.yaml`** — Runs on push/PR touching `iclaw/**` or `tests/**`. Runs unit tests with `coverage` against `iclaw/*.py`, `iclaw/commands/*.py`, `iclaw/tools/*.py`.
40+
- **`.github/workflows/integration.yaml`** — Runs on push/PR touching `mini_copilot/**` or `integration_tests/**`. Skips silently if `GITHUB_TOKEN_INTEGRATION` secret is not set.
41+
42+
## Architecture
2043

2144
**Authentication flow:**
22-
1. `iclaw-login` runs GitHub OAuth Device Flow, saves the GitHub token to `~/.config/iclaw/config.json`
23-
2. On startup, `iclaw` reads the GitHub token from `~/.config/iclaw/config.json`, then exchanges it for a short-lived Copilot token via `https://api.github.com/copilot_internal/v2/token`
24-
3. The Copilot token is refreshed every ~24 minutes during the session
45+
1. `iclaw-login` runs GitHub OAuth Device Flow → saves `github_token` to `~/.config/iclaw/config.json`
46+
2. `iclaw` startup reads the token via `iclaw/config.py:load_github_token()`, then exchanges it for a short-lived Copilot token (`iclaw/github_api.py:get_copilot_token()`)
47+
3. The Copilot token is refreshed every `TOKEN_REFRESH_INTERVAL` seconds (24 min) during the session
48+
49+
**REPL loop (`iclaw/main.py:main()`):**
50+
- Uses `prompt_toolkit.PromptSession` with `IclawCompleter` for tab-completion
51+
- Commands (`/model`, `/copy`, etc.) are handled before messages reach the API
52+
- User input passes through `resolve_at_mentions()` to expand `@filepath` references into `<file>` XML tags prepended to the message
53+
- Calls `github_api.chat()` with the full message history and `TOOLS`
54+
- Handles agentic tool-call loops: the model may return `tool_calls` for `web_search`, `exec`, or `edit`; results are appended as `role: tool` messages and the API is called again until a plain text reply is returned
55+
56+
**Tool definitions (`iclaw/tools/defs.py`):**
57+
- `web_search` — delegates to `iclaw/web_search.py` (DuckDuckGo or Tavily based on `search_provider`)
58+
- `exec` — delegates to `iclaw/exec_tool.py` (runs shell commands locally)
59+
- `edit` — delegates to `iclaw/tools/edit_tool.py` (applies unified diffs to files)
60+
61+
**Key modules:**
62+
- `iclaw/completer.py``IclawCompleter`: `@`-mention file completion (via `git ls-files`) and `/`-command completion
63+
- `iclaw/at_mention.py``resolve_at_mentions()`: expands `@path` tokens into file content XML
64+
- `iclaw/github_api.py``get_copilot_token()`, `get_models()`, `chat()`
65+
- `iclaw/commands/` — handlers for `/model_provider`, `/model`, `/search_provider`, `/copy`
66+
- `iclaw/config.py``CONFIG_PATH`, `TOKEN_REFRESH_INTERVAL`, `load_github_token()`
2567

2668
**API endpoints:**
27-
- `https://github.com/login/device/code` — Device authorization
28-
- `https://github.com/login/oauth/access_token` — Token polling
2969
- `https://api.github.com/copilot_internal/v2/token` — Copilot token exchange
30-
- `https://api.githubcopilot.com/chat/completions` — Chat completions (GPT-4o model)
31-
32-
**Key files:**
33-
- `iclaw/main.py` — Interactive REPL: loads token, maintains conversation history, calls Copilot API
34-
- `iclaw/login.py` — CLI login utility
35-
- `pyproject.toml` — Package metadata and entry points
36-
- `~/.config/iclaw/config.json` — Generated by login, not in repo; contains `{ github_token, created_at }`
70+
- `https://api.githubcopilot.com/chat/completions` — Chat completions
71+
- `https://api.githubcopilot.com/models` — Available model list

iclaw/at_mention.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import os
2+
import re
3+
from pathlib import Path
4+
5+
6+
def resolve_at_mentions(text):
7+
"""Extract @file references, return augmented text with file contents prepended."""
8+
mentions = re.findall(r"@(\S+)", text)
9+
if not mentions:
10+
return text
11+
parts = []
12+
for path in mentions:
13+
if os.path.isfile(path):
14+
try:
15+
contents = Path(path).read_text()
16+
parts.append(f'<file path="{path}">\n{contents}\n</file>')
17+
except OSError:
18+
pass
19+
if parts:
20+
return "\n".join(parts) + "\n\n" + text
21+
return text

iclaw/completer.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import os
2+
import subprocess
3+
4+
from prompt_toolkit.completion import Completer, Completion
5+
6+
COMMANDS = [
7+
"/model_provider",
8+
"/model",
9+
"/search_provider",
10+
"/copy",
11+
"/help",
12+
".exit",
13+
]
14+
15+
16+
def _get_git_files():
17+
"""Return files from git ls-files, natively respecting .gitignore."""
18+
try:
19+
result = subprocess.run(
20+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
21+
capture_output=True,
22+
text=True,
23+
timeout=5,
24+
)
25+
if result.returncode == 0:
26+
return result.stdout.splitlines()
27+
except (FileNotFoundError, subprocess.TimeoutExpired):
28+
pass
29+
return []
30+
31+
32+
class IclawCompleter(Completer):
33+
"""Handles both / command completion and @ file mention completion."""
34+
35+
def get_completions(self, document, complete_event):
36+
text = document.text_before_cursor
37+
38+
# @ file mention: find the last @ not followed by a space
39+
at_pos = text.rfind("@")
40+
if at_pos != -1:
41+
prefix = text[at_pos + 1 :]
42+
if " " not in prefix:
43+
all_files = _get_git_files()
44+
matches = [f for f in all_files if prefix.lower() in f.lower()]
45+
count = 0
46+
for path in sorted(matches):
47+
if count >= 20:
48+
break
49+
count += 1
50+
meta = "dir" if os.path.isdir(path) else "file"
51+
yield Completion(
52+
path,
53+
start_position=-len(prefix),
54+
display=path,
55+
display_meta=meta,
56+
)
57+
return
58+
59+
# / command completion at start of input
60+
stripped = text.lstrip()
61+
if stripped.startswith("/") or stripped == ".":
62+
for cmd in COMMANDS:
63+
if cmd.startswith(stripped):
64+
yield Completion(
65+
cmd,
66+
start_position=-len(stripped),
67+
display=cmd,
68+
)

iclaw/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import json
2+
from pathlib import Path
3+
4+
CONFIG_PATH = Path.home() / ".config" / "iclaw" / "config.json"
5+
TOKEN_REFRESH_INTERVAL = 24 * 60 # seconds
6+
7+
8+
def load_github_token():
9+
if not CONFIG_PATH.exists():
10+
return None
11+
try:
12+
config = json.loads(CONFIG_PATH.read_text())
13+
return config.get("github_token")
14+
except json.JSONDecodeError:
15+
return None

0 commit comments

Comments
 (0)