fujimoto 0.1.0__tar.gz
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.
- fujimoto-0.1.0/.github/workflows/pre-commit.yml +16 -0
- fujimoto-0.1.0/.github/workflows/release.yml +53 -0
- fujimoto-0.1.0/.github/workflows/tests.yml +44 -0
- fujimoto-0.1.0/.gitignore +15 -0
- fujimoto-0.1.0/.pre-commit-config.yaml +25 -0
- fujimoto-0.1.0/.python-version +1 -0
- fujimoto-0.1.0/CLAUDE.md +243 -0
- fujimoto-0.1.0/CONTRIBUTING.md +179 -0
- fujimoto-0.1.0/LICENSE +21 -0
- fujimoto-0.1.0/PKG-INFO +172 -0
- fujimoto-0.1.0/README.md +147 -0
- fujimoto-0.1.0/pyproject.toml +75 -0
- fujimoto-0.1.0/src/fujimoto/__init__.py +0 -0
- fujimoto-0.1.0/src/fujimoto/claude/__init__.py +25 -0
- fujimoto-0.1.0/src/fujimoto/claude/log_parser.py +292 -0
- fujimoto-0.1.0/src/fujimoto/cli.py +1803 -0
- fujimoto-0.1.0/src/fujimoto/config.py +154 -0
- fujimoto-0.1.0/src/fujimoto/git.py +150 -0
- fujimoto-0.1.0/src/fujimoto/terminal.py +52 -0
- fujimoto-0.1.0/src/fujimoto/tmux.py +223 -0
- fujimoto-0.1.0/src/fujimoto/vscode.py +24 -0
- fujimoto-0.1.0/tests/__init__.py +0 -0
- fujimoto-0.1.0/tests/test_claude_log.py +455 -0
- fujimoto-0.1.0/tests/test_cli.py +2453 -0
- fujimoto-0.1.0/tests/test_config.py +305 -0
- fujimoto-0.1.0/tests/test_git.py +362 -0
- fujimoto-0.1.0/tests/test_terminal.py +91 -0
- fujimoto-0.1.0/tests/test_tmux.py +336 -0
- fujimoto-0.1.0/tests/test_vscode.py +35 -0
- fujimoto-0.1.0/uv.lock +406 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
with:
|
|
13
|
+
fetch-depth: 0 # hatch-vcs needs tags to derive the version
|
|
14
|
+
|
|
15
|
+
- uses: astral-sh/setup-uv@v5
|
|
16
|
+
|
|
17
|
+
- name: Build sdist and wheel
|
|
18
|
+
run: uv build
|
|
19
|
+
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish-testpypi:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: testpypi
|
|
29
|
+
permissions:
|
|
30
|
+
id-token: write
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/download-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
38
|
+
with:
|
|
39
|
+
repository-url: https://test.pypi.org/legacy/
|
|
40
|
+
|
|
41
|
+
publish-pypi:
|
|
42
|
+
needs: publish-testpypi
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
environment: pypi
|
|
45
|
+
permissions:
|
|
46
|
+
id-token: write
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/download-artifact@v4
|
|
49
|
+
with:
|
|
50
|
+
name: dist
|
|
51
|
+
path: dist/
|
|
52
|
+
|
|
53
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0 # hatch-vcs needs tags to derive the version
|
|
18
|
+
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
|
|
21
|
+
- name: Install dependencies
|
|
22
|
+
run: uv sync
|
|
23
|
+
|
|
24
|
+
- name: Run tests
|
|
25
|
+
run: uv run pytest --cov-report=json:coverage.json
|
|
26
|
+
|
|
27
|
+
- name: Generate coverage badge
|
|
28
|
+
if: github.ref == 'refs/heads/main'
|
|
29
|
+
run: |
|
|
30
|
+
COVERAGE=$(uv run python -c "import json; print(int(json.load(open('coverage.json'))['totals']['percent_covered_display']))")
|
|
31
|
+
uvx anybadge --value="$COVERAGE" --suffix="%" --file=coverage.svg --label=coverage \
|
|
32
|
+
--overwrite 50=red 60=orange 70=yellow 80=green 90=brightgreen
|
|
33
|
+
|
|
34
|
+
- name: Publish badge
|
|
35
|
+
if: github.ref == 'refs/heads/main'
|
|
36
|
+
run: |
|
|
37
|
+
cp coverage.svg /tmp/coverage.svg
|
|
38
|
+
git config user.name "github-actions[bot]"
|
|
39
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
40
|
+
git fetch origin badges:badges 2>/dev/null && git checkout badges || { git checkout --orphan badges && git rm -rf . ; }
|
|
41
|
+
cp /tmp/coverage.svg coverage.svg
|
|
42
|
+
git add coverage.svg
|
|
43
|
+
git commit -m "Update coverage badge" || true
|
|
44
|
+
git push origin badges --force
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: check-yaml
|
|
6
|
+
- id: trailing-whitespace
|
|
7
|
+
- id: end-of-file-fixer
|
|
8
|
+
- id: no-commit-to-branch
|
|
9
|
+
args: [--branch, main]
|
|
10
|
+
|
|
11
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
12
|
+
rev: v0.15.0
|
|
13
|
+
hooks:
|
|
14
|
+
- id: ruff
|
|
15
|
+
args: [--fix]
|
|
16
|
+
- id: ruff-format
|
|
17
|
+
|
|
18
|
+
- repo: local
|
|
19
|
+
hooks:
|
|
20
|
+
- id: ty
|
|
21
|
+
name: ty
|
|
22
|
+
entry: uv run ty check src/
|
|
23
|
+
language: system
|
|
24
|
+
pass_filenames: false
|
|
25
|
+
types: [python]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
fujimoto-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# fujimoto
|
|
2
|
+
|
|
3
|
+
CLI/TUI tool for managing Claude Code sessions in git worktrees and repositories.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv sync # Install dependencies
|
|
9
|
+
uv run fujimoto # Run locally (must be inside a git repo)
|
|
10
|
+
uv run pytest # Run tests with coverage
|
|
11
|
+
uv tool install --force --reinstall . # Install globally (re-run after code changes)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Environment Variables (all optional)
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
export FUJIMOTO_WORKTREE_ROOT=~/git/worktrees/ # Optional: where worktrees are created
|
|
18
|
+
export FUJIMOTO_GIT_ROOT=~/git/ # Optional: enables project switching
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If `FUJIMOTO_WORKTREE_ROOT` is unset, worktrees are created at
|
|
22
|
+
`<repo_root>/.fujimoto/worktrees/` (the `.fujimoto/` directory is auto-gitignored
|
|
23
|
+
via a `.gitignore` containing `*`). If `FUJIMOTO_GIT_ROOT` is unset, the project
|
|
24
|
+
switcher is silently hidden.
|
|
25
|
+
|
|
26
|
+
## Prerequisites
|
|
27
|
+
|
|
28
|
+
- Python 3.11+
|
|
29
|
+
- tmux (auto-installs via brew if missing)
|
|
30
|
+
- git
|
|
31
|
+
|
|
32
|
+
## Project Structure
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/fujimoto/
|
|
36
|
+
├── __init__.py
|
|
37
|
+
├── cli.py # Textual TUI app, entry point (main()), all UI screens and event handlers
|
|
38
|
+
├── config.py # Environment variable loading, path construction, session metadata
|
|
39
|
+
├── git.py # Git subprocess wrappers (worktree lifecycle, branch operations)
|
|
40
|
+
├── terminal.py # Open native terminal windows (iTerm2 with Terminal.app fallback)
|
|
41
|
+
├── vscode.py # Open directories in VS Code via the `code` CLI
|
|
42
|
+
├── tmux.py # tmux session lifecycle (create, attach, kill, list, install)
|
|
43
|
+
└── claude/
|
|
44
|
+
├── __init__.py # Re-exports public API
|
|
45
|
+
└── log_parser.py # Parse Claude JSONL session logs (state, metadata, session lookup)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
### Entry Point
|
|
51
|
+
|
|
52
|
+
`cli.py:main()` is the package entry point (`pyproject.toml` `[project.scripts]`). It:
|
|
53
|
+
1. Runs the Textual `SessionApp` in a loop
|
|
54
|
+
2. After the TUI exits, calls `launch_claude_in_tmux()` if the user selected a session
|
|
55
|
+
3. When the tmux session is detached, the loop restarts and the TUI reappears
|
|
56
|
+
4. The loop exits when the user quits the TUI (q/escape/ctrl+c) without selecting a session
|
|
57
|
+
|
|
58
|
+
### Session Types
|
|
59
|
+
|
|
60
|
+
**Worktree sessions** — isolated git worktree with its own branch:
|
|
61
|
+
- Creates a new branch + working directory via `git worktree add`
|
|
62
|
+
- Finish flow: Push & Create PR, Cherry-pick to base branch, or Discard & Delete
|
|
63
|
+
- Session metadata (base branch) stored in `.fujimoto/meta.json` (auto-gitignored)
|
|
64
|
+
|
|
65
|
+
**Direct sessions** — Claude launched in an existing repo directory:
|
|
66
|
+
- No worktree creation, uses the repo's current branch
|
|
67
|
+
- Multiple concurrent sessions possible on same repo
|
|
68
|
+
- Named `{project}/direct-N` in tmux
|
|
69
|
+
|
|
70
|
+
**Ad hoc sessions** — Claude launched in a temporary directory, outside any git project:
|
|
71
|
+
- For quick questions, investigations, and one-off tasks
|
|
72
|
+
- Working directory is a `tempfile.mkdtemp(prefix="fujimoto-adhoc-")` temp dir
|
|
73
|
+
- Named `adhoc-N` in tmux (not project-scoped)
|
|
74
|
+
- System prompt tells Claude there is no git repository
|
|
75
|
+
|
|
76
|
+
### Module Responsibilities
|
|
77
|
+
|
|
78
|
+
**`config.py`** — Pure functions, no side effects except directory creation:
|
|
79
|
+
- `get_worktree_root(project_root=None)` — returns `FUJIMOTO_WORKTREE_ROOT` if set, else falls back to `<project_root>/.fujimoto/worktrees/` (ensures `.fujimoto/.gitignore` exists). Raises `ConfigError` only if both are missing.
|
|
80
|
+
- `get_git_projects_root()` — reads `FUJIMOTO_GIT_ROOT`, returns `None` if unset
|
|
81
|
+
- `list_projects()` — scans git root for directories containing `.git`
|
|
82
|
+
- `slugify(title)` — lowercase, replace non-alphanumeric with hyphens, strip/collapse
|
|
83
|
+
- `build_worktree_path(project, title, project_root=None)` — with env var: `{root}/{project}/{YYYYMMDD}-{slug}`; with fallback: `<project_root>/.fujimoto/worktrees/{YYYYMMDD}-{slug}`
|
|
84
|
+
- `get_project_worktrees_dir(project, project_root=None)` — with env var: `{root}/{project}`; with fallback: `<project_root>/.fujimoto/worktrees/`
|
|
85
|
+
- `store_session_meta(path, base_branch)` / `read_session_meta(path)` — JSON metadata
|
|
86
|
+
- `get_next_direct_session_name(project, sessions)` — computes `{project}/direct-N`
|
|
87
|
+
- `get_next_adhoc_session_name(sessions)` — computes `adhoc-N`
|
|
88
|
+
|
|
89
|
+
**`git.py`** — Thin wrappers around `git` subprocess calls:
|
|
90
|
+
- `_run(args, cwd)` — subprocess runner, raises `GitError` on non-zero exit
|
|
91
|
+
- `get_repo_root()` — `git rev-parse --show-toplevel`
|
|
92
|
+
- `get_project_name()` — basename of repo root
|
|
93
|
+
- `get_current_branch()` — `git branch --show-current`
|
|
94
|
+
- `get_default_branch()` — tries `symbolic-ref`, falls back to checking main/master
|
|
95
|
+
- `fetch_and_rebase_branch(branch)` — `git fetch origin` + `git rebase origin/{branch}`
|
|
96
|
+
- `list_branches()` — sorted list of local branch names
|
|
97
|
+
- `create_worktree(path, base_branch, new_branch)` — `git worktree add -b`
|
|
98
|
+
- `remove_worktree(path)` — `git worktree remove --force`
|
|
99
|
+
- `get_unpushed_commits(branch)` — commits not yet on remote
|
|
100
|
+
- `get_merge_base(branch)` — fork point from default branch
|
|
101
|
+
- `is_branch_merged(branch, into)` — `git merge-base --is-ancestor`
|
|
102
|
+
- `has_remote_branch(branch)` — `git ls-remote --heads`
|
|
103
|
+
- `push_branch(branch)` — `git push -u origin`
|
|
104
|
+
- `delete_branch(branch, remote)` — `git branch -D`, optionally remote
|
|
105
|
+
- `cherry_pick_branch(branch, onto)` — cherry-picks commit range onto target
|
|
106
|
+
|
|
107
|
+
**`terminal.py`** — Open native terminal windows in a session's directory:
|
|
108
|
+
- `open_terminal(directory)` — opens iTerm2 if installed, otherwise Terminal.app. Raises `OSError` on non-macOS.
|
|
109
|
+
- `_has_iterm()` — checks for `/Applications/iTerm.app`
|
|
110
|
+
- `_open_iterm(directory)` — AppleScript to create new iTerm2 window
|
|
111
|
+
- `_open_terminal_app(directory)` — `open -a Terminal` fallback
|
|
112
|
+
|
|
113
|
+
**`vscode.py`** — Open directories in VS Code:
|
|
114
|
+
- `open_vscode(directory)` — runs `code <directory>`. Raises `OSError` if the `code` CLI is not on PATH.
|
|
115
|
+
- `_has_vscode()` — checks for `code` on PATH via `shutil.which`
|
|
116
|
+
|
|
117
|
+
**`tmux.py`** — tmux session management:
|
|
118
|
+
- `is_tmux_installed()` / `install_tmux()` — detection and brew install
|
|
119
|
+
- `list_all_sessions()` — lists all active tmux session names
|
|
120
|
+
- `list_project_sessions(project)` — lists active tmux sessions for a project
|
|
121
|
+
- `session_name(project, dir)` — naming convention: `{project}/{dir}`
|
|
122
|
+
- `create_session(name, dir, system_prompt, resume_session_id)` — creates detached session, sets prefix to Ctrl+A, runs `claude` (with optional `--resume`)
|
|
123
|
+
- `create_session_with_command(name, dir, command)` — like `create_session` but with custom command
|
|
124
|
+
- `kill_session(name)` — `tmux kill-session -t`
|
|
125
|
+
- `attach_session(name)` — prints shortcut banner, then `subprocess.run` tmux attach (returns on detach)
|
|
126
|
+
- `launch_claude_in_tmux(project, path, tmux_name, system_prompt, resume_session_id)` — orchestrates create-or-attach, supports resuming previous Claude sessions
|
|
127
|
+
|
|
128
|
+
**`claude/log_parser.py`** — Parse Claude Code's JSONL session logs:
|
|
129
|
+
- `ClaudeLogError` — raised on empty/unreadable logs
|
|
130
|
+
- `EntryType` / `StopReason` / `SessionState` — StrEnums with lenient `from_raw()` parsing (returns `None` for unrecognized values)
|
|
131
|
+
- `ClaudeSession` — frozen dataclass: session_id, state, cwd, git_branch, last_activity, etc.
|
|
132
|
+
- `encode_project_path(path)` — `str(path).replace("/", "-")` (matches Claude's directory encoding)
|
|
133
|
+
- `get_claude_projects_dir()` — `~/.claude/projects`
|
|
134
|
+
- `parse_session(jsonl_path)` — reads JSONL, tracks last meaningful (non-sidechain) entry, derives state
|
|
135
|
+
- `get_sessions_for_path(project_path)` — encodes path, globs `*.jsonl`, returns sorted sessions
|
|
136
|
+
|
|
137
|
+
**`cli.py`** — Textual TUI with async view management:
|
|
138
|
+
- `SessionInfo` — dataclass for session state (type, project, path, tmux name, active status, claude_session_id, claude_state)
|
|
139
|
+
- `SessionApp` — main app class with CSS styling
|
|
140
|
+
- Module-level helpers: `_claude_state_label(state)`, `_relative_time(dt)`, `_get_claude_sessions(root, worktrees)`
|
|
141
|
+
- Instance helpers: `_build_session_label(session, state_suffix)` — generates label text for session items, used by both `_show_home` initial render and `_poll_session_states` in-place updates
|
|
142
|
+
- Views: home (sessions list), session actions submenu, finish flow, confirm dialog, create form, branch select (3 options), branch picker (filterable list), conflict resolution, project switcher (with autocomplete filter), tmux install, error
|
|
143
|
+
- Home screen sections: actions ("New worktree session", "New session in X", "Ad hoc session"), active sessions (with Claude state indicators), inactive worktrees (with Claude state), previous Claude sessions (resumable, capped at 5), switch project
|
|
144
|
+
- Worktree create flow: title → branch select (default w/ fetch & rebase, current branch, another branch → picker) → create
|
|
145
|
+
- Session actions submenu: Connect/Launch, Resume previous session (worktree/direct), Terminate, Resume (claude sessions), Rename, Open terminal, Open in VS Code, Finish (worktree only)
|
|
146
|
+
- Finish flow: Push & Create PR (background Claude), Cherry-pick to base, Discard & Delete
|
|
147
|
+
- All view transitions are `async` — `await _clear_main()` then `await mount()`
|
|
148
|
+
- Session data stored in `_session_map` dict keyed by ListItem ID
|
|
149
|
+
- `_launch_target` is `(project, path, tmux_name, session_type, resume_id)`, set before `self.exit()`
|
|
150
|
+
|
|
151
|
+
### Error Handling
|
|
152
|
+
|
|
153
|
+
Three custom exception types, all caught in `main()`:
|
|
154
|
+
- `ConfigError` — missing env var
|
|
155
|
+
- `GitError` — git command failures, not in a repo
|
|
156
|
+
- `TmuxError` — tmux not installed, install failure
|
|
157
|
+
|
|
158
|
+
### Naming Conventions
|
|
159
|
+
|
|
160
|
+
| Thing | Pattern | Example |
|
|
161
|
+
|-------|---------|---------|
|
|
162
|
+
| Worktree directory | `{YYYYMMDD}-{slug}` | `20260309-fix-unit-tests` |
|
|
163
|
+
| Git branch | `worktree/{dir-name}` | `worktree/20260309-fix-unit-tests` |
|
|
164
|
+
| tmux session (worktree) | `{project}/{dir-name}` | `qsic-data/20260309-fix-unit-tests` |
|
|
165
|
+
| tmux session (direct) | `{project}/direct-{N}` | `qsic-data/direct-1` |
|
|
166
|
+
| tmux session (adhoc) | `adhoc-{N}` | `adhoc-1` |
|
|
167
|
+
| Widget ID (direct) | `ds-{project}--direct-{N}` | `ds-qsic-data--direct-1` |
|
|
168
|
+
| Widget ID (claude session) | `cs-{session-id}` | `cs-abc12345-def6-7890` |
|
|
169
|
+
|
|
170
|
+
### Key Design Decisions
|
|
171
|
+
|
|
172
|
+
- **TUI loop with tmux detach**: The TUI runs in a `while True` loop. After tmux detach (subprocess.run returns), the loop restarts and the TUI reappears. The loop breaks when the user quits without selecting a session.
|
|
173
|
+
- **Per-session tmux config**: Prefix remapped to Ctrl+A, status bar with shortcut hints — all set via `tmux set-option -t` so the user's global config is untouched.
|
|
174
|
+
- **Global install via `uv tool`**: Requires `--force --reinstall` to rebuild the wheel from source. Plain `--force` reuses cached builds.
|
|
175
|
+
- **Session metadata**: `.fujimoto/meta.json` stored in worktree directory records the base branch for cherry-pick targeting. The `.fujimoto/` directory contains a `.gitignore` with `*` so its contents are automatically ignored by git.
|
|
176
|
+
- **Background PR creation**: Uses `claude -p --allowedTools "Bash(git:*) Bash(gh:*)"` in a tmux session for unattended PR creation.
|
|
177
|
+
- **Claude session integration**: The home screen fetches Claude session state from `~/.claude/projects/` JSONL logs via the log parser. Session states: 👀 awaiting input (`WAITING_FOR_USER`), 🛡️ approve tool (`WAITING_FOR_TOOL_APPROVAL`), ⚙ working (`WORKING`), 💤 idle (`IDLE`), no indicator (`UNKNOWN`). State logic: `last-prompt` marker → `IDLE` (session ended). For assistant entries: `stop_reason=tool_use` without a following `tool_result` → `WAITING_FOR_TOOL_APPROVAL` (pending user approval), `stop_reason=tool_use` with `tool_result` → `WORKING`, any other stop reason or no stop reason → `WAITING_FOR_USER`. Last entry is user → `WORKING`. Previous Claude sessions (from the project root, capped at 5) appear as resumable items. Resuming launches `claude --resume SESSION_ID` in a new tmux session. The latest Claude session per path is "claimed" by the corresponding tmux/worktree item to avoid duplication.
|
|
178
|
+
- **Resume previous session — tmux naming**: When resuming from an inactive worktree, the resumed session reuses the worktree's existing tmux session name (e.g., `project/20260101-feature`) instead of generating a new `direct-N` name. This keeps the session correctly identified as a worktree item on subsequent TUI views, so its path and Claude session lookup remain tied to the worktree directory. For active worktrees (original session still alive), a `direct-N` name is used because the worktree name is occupied. The working directory for resumed sessions always comes from `cs.cwd` (the directory recorded in the Claude session log) rather than `session.path`.
|
|
179
|
+
- **Live polling**: The home screen uses `set_interval(3s)` to poll Claude JSONL logs for state changes. When a session's state changes, labels are updated in-place via `label.update()` — the screen is never cleared or rebuilt, which avoids blank-screen flicker. A snapshot dict (`path → (session_id, state)`) is compared each tick to detect changes efficiently. The timer is stopped when navigating away (`_clear_main` cancels it) and restarted by `_show_home`.
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
Tests use pytest with pytest-asyncio for TUI tests and pytest-cov for coverage. Run with:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
uv run pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Coverage is reported automatically (configured in `pyproject.toml`).
|
|
190
|
+
|
|
191
|
+
**Maximize test coverage.** Write tests for all new code — unit tests for logic, async TUI tests using Textual's `app.run_test()` pilot for UI flows. Only skip coverage for lines that are genuinely impractical to test (e.g. defensive error handlers in deeply nested async TUI paths that can't be triggered through the pilot). Use `# pragma: no cover` sparingly and only with justification.
|
|
192
|
+
|
|
193
|
+
TUI tests follow this pattern:
|
|
194
|
+
- Patch external dependencies (`git`, `tmux`, `config`) via `_patch_git_info()` helper
|
|
195
|
+
- Use `async with app.run_test() as pilot:` to drive the UI
|
|
196
|
+
- Use `pilot.press()` to simulate keyboard input
|
|
197
|
+
- Navigate to items by setting `list.index` directly (more reliable than repeated `pilot.press("down")`)
|
|
198
|
+
- Assert on app state (`_launch_target`, `_base_branch`, `_session_map`) and DOM queries (`app.query()`)
|
|
199
|
+
|
|
200
|
+
## Documentation
|
|
201
|
+
|
|
202
|
+
**Keep documentation in sync with code changes.** When making changes to the codebase:
|
|
203
|
+
|
|
204
|
+
- **CLAUDE.md**: Update architecture, module responsibilities, naming conventions, and design decisions to reflect the current state. This is the primary reference — it must always be accurate.
|
|
205
|
+
- **README.md**: Update user-facing docs (usage, home screen layout, features, configuration) when UI or behaviour changes.
|
|
206
|
+
- **CONTRIBUTING.md**: Update developer guidance (project layout, manual testing steps, view patterns) when internal structure changes.
|
|
207
|
+
|
|
208
|
+
When you discover something new about the codebase, tooling, or patterns during a session — incorporate it into the appropriate documentation file rather than leaving it as tribal knowledge.
|
|
209
|
+
|
|
210
|
+
## Gotchas and Learnings
|
|
211
|
+
|
|
212
|
+
Things discovered during development that are easy to forget:
|
|
213
|
+
|
|
214
|
+
- **Textual widget IDs cannot contain `/`**. tmux session names use `project/name` but widget IDs must use `--` as separator (e.g. `ds-qsic-data--direct-1`).
|
|
215
|
+
- **`git worktree remove` needs `--force`** for worktrees with uncommitted changes — without it the command fails silently in some states.
|
|
216
|
+
- **`git reflog` records branch creation origin** (`branch: Created from main`) — useful for recovering the base branch if `.fujimoto/meta.json` is missing.
|
|
217
|
+
- **`claude -p` (print mode)** runs non-interactively. For background tasks, pair with `--allowedTools` to scope permissions rather than `--dangerously-skip-permissions`.
|
|
218
|
+
- **Global find-replace for renames** works well but always verify test patch target strings — they are plain strings not checked by the import system. Run the full test suite after any rename.
|
|
219
|
+
- **Claude log entry types evolve** — real logs contain `last-prompt`, `queue-operation`, `progress` and other types beyond `assistant`/`user`/`system`/`file-history-snapshot`. The parser skips unrecognized types gracefully. `last-prompt` signals session end → `IDLE` state. `stop_reason=None` on assistant entries means interrupted/canceled (Esc) → `WAITING_FOR_USER`. Always smoke-test against real `~/.claude/projects/` data after changes.
|
|
220
|
+
- **Shift+Enter in tmux requires `extended-keys always` globally** — tmux strips modifier info by default, making Shift+Enter identical to Enter. The fix requires two server/global-level settings: `set-option -g extended-keys always` and `set-option -s -a terminal-features xterm*:extkeys`. Per-session (`-t`) doesn't work. `extended-keys on` (vs `always`) doesn't work because Claude Code doesn't send the kitty keyboard protocol activation sequence. Requires tmux 3.2+. See `_ensure_extended_keys()` in `tmux.py`.
|
|
221
|
+
|
|
222
|
+
## Releases
|
|
223
|
+
|
|
224
|
+
Releases are published to PyPI by `.github/workflows/release.yml` on `v*`
|
|
225
|
+
tag push. The package version is **derived from the git tag** by `hatch-vcs`
|
|
226
|
+
(`dynamic = ["version"]` in `pyproject.toml`) — do not add a static `version`
|
|
227
|
+
field, do not edit a version string when cutting a release. The release flow,
|
|
228
|
+
recovery procedures, and one-time setup are documented in
|
|
229
|
+
[CONTRIBUTING.md](CONTRIBUTING.md#releasing).
|
|
230
|
+
|
|
231
|
+
## Git Commits and PRs
|
|
232
|
+
|
|
233
|
+
Do not mention Claude or AI when authoring git commits or pull requests. No co-authored-by lines referencing Claude.
|
|
234
|
+
|
|
235
|
+
## Linting and Type Checking
|
|
236
|
+
|
|
237
|
+
Pre-commit hooks handle all linting and formatting automatically — do not run `ruff`, `ty`, or other linters manually. Let the hooks run at commit time and fix any issues they report. Any new linting or formatting tools should be added to `.pre-commit-config.yaml`, not run ad hoc.
|
|
238
|
+
|
|
239
|
+
Current hooks:
|
|
240
|
+
- **ruff** — linting and formatting
|
|
241
|
+
- **ty** — type checking (strict: no `unresolved-attribute` allowed)
|
|
242
|
+
|
|
243
|
+
All widget state must use typed instance variables, not dynamic attributes on Textual widgets.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Getting Started
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
git clone https://github.com/jongracecox/fujimoto.git
|
|
7
|
+
cd fujimoto
|
|
8
|
+
uv sync
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Running Locally
|
|
12
|
+
|
|
13
|
+
You must be inside a git repository. `FUJIMOTO_WORKTREE_ROOT` is optional —
|
|
14
|
+
when unset, worktrees are created at `<repo>/.fujimoto/worktrees/`:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
export FUJIMOTO_WORKTREE_ROOT=~/git/worktrees/ # optional
|
|
18
|
+
uv run fujimoto
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Installing Globally
|
|
22
|
+
|
|
23
|
+
After making changes, reinstall to test the global `fujimoto` command:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv tool install --force --reinstall .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Both `--force` and `--reinstall` are required — `--force` alone reuses cached wheel builds and won't pick up code changes.
|
|
30
|
+
|
|
31
|
+
## Project Layout
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
src/fujimoto/
|
|
35
|
+
├── cli.py # Textual TUI app and entry point
|
|
36
|
+
├── config.py # Env var loading, path construction, session metadata
|
|
37
|
+
├── git.py # Git subprocess wrappers
|
|
38
|
+
├── tmux.py # tmux session management
|
|
39
|
+
└── claude/
|
|
40
|
+
├── __init__.py # Re-exports public API
|
|
41
|
+
└── log_parser.py # Parse Claude JSONL session logs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See [CLAUDE.md](CLAUDE.md) for detailed architecture documentation.
|
|
45
|
+
|
|
46
|
+
## Code Style
|
|
47
|
+
|
|
48
|
+
Pre-commit hooks enforce:
|
|
49
|
+
|
|
50
|
+
- **ruff** for linting and auto-formatting
|
|
51
|
+
- **ty** for type checking
|
|
52
|
+
|
|
53
|
+
Hooks run automatically on `git commit`. If a hook fails, it may auto-fix files — re-stage and commit again.
|
|
54
|
+
|
|
55
|
+
### Type Safety
|
|
56
|
+
|
|
57
|
+
The ty type checker runs in strict mode. Key rules:
|
|
58
|
+
|
|
59
|
+
- Do not set dynamic attributes on Textual widgets (e.g. `item._data = value`). Use a dictionary on the app instance instead.
|
|
60
|
+
- All instance variables must be declared with type annotations in `__init__`.
|
|
61
|
+
|
|
62
|
+
## Architecture Notes
|
|
63
|
+
|
|
64
|
+
### TUI View Pattern
|
|
65
|
+
|
|
66
|
+
All views follow the same async pattern:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
async def _show_some_view(self) -> None:
|
|
70
|
+
await self._clear_main() # Remove all children from #main
|
|
71
|
+
main = self.query_one("#main")
|
|
72
|
+
await main.mount( # Mount new widgets
|
|
73
|
+
Container(...)
|
|
74
|
+
)
|
|
75
|
+
self.query_one("#some-widget").focus() # Set focus
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every `remove_children()` and `mount()` call must be awaited to prevent DOM race conditions.
|
|
79
|
+
|
|
80
|
+
### tmux Handoff
|
|
81
|
+
|
|
82
|
+
The Textual app cannot run simultaneously with tmux attach (both need the terminal). The pattern is:
|
|
83
|
+
|
|
84
|
+
1. TUI sets `self._launch_target = (project, path, tmux_name)`
|
|
85
|
+
2. TUI calls `self.exit()` to cleanly shut down the event loop
|
|
86
|
+
3. `main()` reads `_launch_target` after `app.run()` returns
|
|
87
|
+
4. `launch_claude_in_tmux()` creates or attaches to the tmux session
|
|
88
|
+
|
|
89
|
+
### Adding a New View
|
|
90
|
+
|
|
91
|
+
1. Add an `async def _show_*` method following the pattern above
|
|
92
|
+
2. Add a `@on(ListView.Selected, "#your-list-id")` handler
|
|
93
|
+
3. Wire navigation from an existing view
|
|
94
|
+
4. Add any new state to `__init__` with type annotations
|
|
95
|
+
|
|
96
|
+
### Adding New Git/tmux Operations
|
|
97
|
+
|
|
98
|
+
- Git wrappers go in `git.py` using the `_run()` helper
|
|
99
|
+
- tmux operations go in `tmux.py` using `subprocess.run`
|
|
100
|
+
- Both should raise their respective error types (`GitError`, `TmuxError`)
|
|
101
|
+
- Import and use them in `cli.py`
|
|
102
|
+
|
|
103
|
+
### Claude Log Integration
|
|
104
|
+
|
|
105
|
+
The `claude/` subpackage parses Claude Code's JSONL session logs (`~/.claude/projects/`). Not yet wired into the TUI — currently a standalone module with its own test suite.
|
|
106
|
+
|
|
107
|
+
- Add new entry types or stop reasons to the StrEnums in `log_parser.py` — unknown values raise `ClaudeLogError` immediately
|
|
108
|
+
- Path encoding matches Claude's convention: `str(path).replace("/", "-")`
|
|
109
|
+
- Test with `tmp_path` fixtures — never access real `~/.claude/` in tests
|
|
110
|
+
|
|
111
|
+
## Releasing
|
|
112
|
+
|
|
113
|
+
Releases are published to PyPI automatically by `.github/workflows/release.yml`
|
|
114
|
+
when a tag matching `v*` is pushed.
|
|
115
|
+
|
|
116
|
+
### Versioning
|
|
117
|
+
|
|
118
|
+
The package version is derived from git tags by `hatch-vcs` — there is **no**
|
|
119
|
+
`version` field in `pyproject.toml` to maintain. The version of a built
|
|
120
|
+
artifact is whatever git tag points at the build commit (e.g. tag `v0.1.1` →
|
|
121
|
+
package version `0.1.1`). Builds from untagged commits get a dev version like
|
|
122
|
+
`0.1.1.dev3`.
|
|
123
|
+
|
|
124
|
+
### Cutting a release
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
git checkout main
|
|
128
|
+
git pull
|
|
129
|
+
git tag v0.1.1
|
|
130
|
+
git push origin v0.1.1
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The workflow will then:
|
|
134
|
+
|
|
135
|
+
1. **Build** sdist + wheel with `uv build` (single artifact reused by both
|
|
136
|
+
publish jobs).
|
|
137
|
+
2. **Publish to TestPyPI** — waits for manual approval in the `testpypi`
|
|
138
|
+
GitHub environment. After approval, uploads via OIDC trusted publishing.
|
|
139
|
+
3. **Publish to PyPI** — waits for a second manual approval in the `pypi`
|
|
140
|
+
environment, then uploads.
|
|
141
|
+
|
|
142
|
+
Approve in **Actions → release run → Review deployments**. Between the two
|
|
143
|
+
approvals, sanity-check the upload at https://test.pypi.org/project/fujimoto/.
|
|
144
|
+
|
|
145
|
+
### Recovering from a bad release
|
|
146
|
+
|
|
147
|
+
PyPI/TestPyPI versions are immutable — you cannot re-upload `v0.1.1` once
|
|
148
|
+
published. To fix issues:
|
|
149
|
+
|
|
150
|
+
- **Caught at TestPyPI stage**: reject the PyPI deployment in Actions, fix
|
|
151
|
+
the problem, push a new tag (e.g. `v0.1.2`).
|
|
152
|
+
- **Caught after PyPI publish**: yank the broken version on pypi.org
|
|
153
|
+
(Manage → Yank), then push a fix tag.
|
|
154
|
+
|
|
155
|
+
Never delete and re-push a tag — the immutability rule still applies on the
|
|
156
|
+
registry side.
|
|
157
|
+
|
|
158
|
+
### One-time setup (already done)
|
|
159
|
+
|
|
160
|
+
- TestPyPI and PyPI projects exist with pending trusted publishers configured
|
|
161
|
+
for this repo, workflow `release.yml`, environments `testpypi` and `pypi`.
|
|
162
|
+
- GitHub `testpypi` and `pypi` environments exist with required reviewers.
|
|
163
|
+
|
|
164
|
+
If these need to be reconfigured, see PyPA's Trusted Publishing docs:
|
|
165
|
+
https://docs.pypi.org/trusted-publishers/
|
|
166
|
+
|
|
167
|
+
## Testing Manually
|
|
168
|
+
|
|
169
|
+
1. Run `fujimoto` from a git repo
|
|
170
|
+
2. Create a new worktree — verify the directory and git branch are created
|
|
171
|
+
3. Detach from tmux (`Ctrl+A D`)
|
|
172
|
+
4. Run `fujimoto` again — the worktree should show a green circle, direct sessions listed
|
|
173
|
+
5. Select an existing session — should show the actions submenu (Connect/Launch/Finish)
|
|
174
|
+
6. Test the Finish flow on a worktree with unpushed commits
|
|
175
|
+
7. Test error cases:
|
|
176
|
+
- Run outside a git repo
|
|
177
|
+
- Create a worktree with a name that already exists
|
|
178
|
+
8. With `FUJIMOTO_WORKTREE_ROOT` unset: confirm worktrees land in
|
|
179
|
+
`<repo>/.fujimoto/worktrees/` and the directory is gitignored
|
fujimoto-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jon Grace-Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|