Skip to content

Commit 48099c0

Browse files
authored
feat: Add community plugin installer with git repository support (#183)
1 parent a30f0a4 commit 48099c0

File tree

19 files changed

+1715
-89
lines changed

19 files changed

+1715
-89
lines changed

.claude/docs/community-plugins.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Community Plugin Installer
2+
3+
## Overview
4+
5+
Titan supports installing plugins from user-provided git repositories, not just the official ones bundled with the CLI. Installation always uses `pipx inject` to keep the plugin isolated in Titan's own venv.
6+
7+
Community plugins are tracked separately in `~/.titan/community_plugins.toml` (global, not per-project).
8+
9+
---
10+
11+
## Key Files
12+
13+
| File | Role |
14+
|------|------|
15+
| `titan_cli/core/plugins/community.py` | All business logic: URL parsing, host detection, pyproject.toml fetch, pipx install/uninstall, tracking file I/O |
16+
| `titan_cli/ui/tui/screens/install_plugin_screen.py` | 4-step install wizard |
17+
| `titan_cli/ui/tui/screens/plugin_management.py` | Modified: install button (`i`), uninstall (`u`), `[community]` badge |
18+
| `titan_cli/ui/tui/widgets/wizard.py` | Shared wizard widgets: `StepStatus`, `WizardStep`, `StepIndicator` |
19+
20+
---
21+
22+
## URL Format
23+
24+
Users must always include an explicit version — bare URLs without `@version` are rejected:
25+
26+
```
27+
# Accepted
28+
https://github.com/user/titan-plugin-custom@v1.2.0
29+
https://github.com/user/titan-plugin-custom@abc123def456
30+
31+
# Rejected
32+
https://github.com/user/titan-plugin-custom
33+
```
34+
35+
Internally this becomes: `git+https://github.com/user/titan-plugin-custom.git@v1.2.0`
36+
37+
---
38+
39+
## Install Flow (4 steps)
40+
41+
### 1. URL
42+
User enters `https://repo@version`. Validated with `validate_url()` before advancing.
43+
44+
### 2. Preview
45+
Fetches `pyproject.toml` from the repo at that exact version and shows:
46+
- Package name, version, description, authors
47+
- Titan entry points registered (`[titan.plugins]`)
48+
- Python dependencies
49+
50+
**Host detection** (`PluginHost` StrEnum): `GITHUB`, `GITLAB`, `BITBUCKET`, `UNKNOWN`
51+
52+
Raw URL patterns per host:
53+
```
54+
GitHub: https://raw.githubusercontent.com/{path}/{version}/pyproject.toml
55+
GitLab: https://gitlab.com/{path}/-/raw/{version}/pyproject.toml
56+
Bitbucket: https://bitbucket.org/{path}/raw/{version}/pyproject.toml
57+
Unknown: fetch skipped — warning shown, install still allowed
58+
```
59+
60+
Error cases (all allow proceeding):
61+
- HTTP 404 → URL or version not found
62+
- Network error → connection problem
63+
- No `[titan.plugins]` entry point → warns plugin won't be visible in Titan
64+
- Unparseable pyproject.toml → warns metadata unreadable
65+
66+
A **security warning** is always shown regardless of outcome.
67+
68+
### 3. Install
69+
Runs `pipx inject titan-cli git+<url>.git@<version>` as a subprocess (async, non-blocking).
70+
71+
Requires Titan to be running inside a pipx environment (`is_running_in_pipx()`). If not, shows an error and blocks install.
72+
73+
On success:
74+
1. Saves record to `~/.titan/community_plugins.toml`
75+
2. Calls `self.config.load()` → auto-reloads registry + re-initializes all plugins (no restart needed)
76+
77+
On failure: shows pipx stderr + actionable suggestions.
78+
79+
### 4. Done
80+
Success or failure summary. "Finish" dismisses the wizard.
81+
82+
---
83+
84+
## Tracking File
85+
86+
`~/.titan/community_plugins.toml` — global, one record per installed community plugin:
87+
88+
```toml
89+
[[plugins]]
90+
repo_url = "https://github.com/user/titan-plugin-custom"
91+
version = "v1.2.0"
92+
package_name = "titan-plugin-custom"
93+
titan_plugin_name = "custom"
94+
installed_at = "2026-03-06T10:30:00+00:00"
95+
```
96+
97+
Key functions in `community.py`:
98+
- `load_community_plugins()``list[CommunityPluginRecord]`
99+
- `save_community_plugin(record)` → appends to file
100+
- `remove_community_plugin(package_name)` → removes by package name
101+
- `get_community_plugin_names()``set[str]` of titan_plugin_names (used for `[community]` badge)
102+
- `get_community_plugin_by_titan_name(name)``Optional[CommunityPluginRecord]`
103+
104+
---
105+
106+
## Uninstall Flow
107+
108+
In Plugin Management, pressing `u` (or clicking "Uninstall") on a community plugin:
109+
1. Runs `pipx runpip titan-cli uninstall -y <package_name>`
110+
2. Calls `remove_community_plugin(package_name)`
111+
3. Calls `self.config.load()` to reload registry
112+
4. Refreshes the plugin list
113+
114+
Only community plugins show the Uninstall button/keybinding.
115+
116+
---
117+
118+
## Wizard Widgets (`titan_cli/ui/tui/widgets/wizard.py`)
119+
120+
Shared by `install_plugin_screen.py` and `plugin_config_wizard.py`:
121+
122+
```python
123+
class StepStatus(StrEnum):
124+
PENDING = "pending"
125+
IN_PROGRESS = "in_progress"
126+
COMPLETED = "completed"
127+
128+
@dataclass
129+
class WizardStep:
130+
id: str
131+
title: str
132+
133+
class StepIndicator(Static):
134+
def __init__(self, step_number: int, step: WizardStep, status: StepStatus): ...
135+
```
136+
137+
Exported from `titan_cli/ui/tui/widgets/__init__.py`.
138+
139+
---
140+
141+
## Known Technical Debt
142+
143+
`plugin_config_wizard.py` still uses plain dicts (`{"id": ..., "title": ...}`) for its steps instead of `WizardStep`. It wraps them with `WizardStep(id=step["id"], title=step["title"])` when calling `StepIndicator`. Full refactor is pending a future PR.

.titan/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ auto_assign_prs = true
2323
enabled = false
2424

2525
[plugins.jira.config]
26-
default_project = "ECAPP"
26+
default_project = "ECAPP"

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ Each plugin is an independent Python package that can register:
206206
#### Modern Plugin Architecture (2026-02)
207207

208208
**📖 [Complete Plugin Architecture Guide](.claude/docs/plugin-architecture.md)**
209+
**📖 [Community Plugin Installer](.claude/docs/community-plugins.md)** — installing plugins from git repos
209210

210211
Plugins now follow a **5-layer architecture** for clean separation of concerns:
211212

tests/engine/steps/test_command_step.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
def mock_context():
1414
"""Provides a mock WorkflowContext."""
1515
ctx = MagicMock(spec=WorkflowContext)
16-
ctx.ui = MagicMock()
17-
ctx.ui.text = MagicMock()
16+
ctx.textual = MagicMock()
1817
ctx.get.side_effect = lambda key, default=None: {"cwd": "/tmp/mock_cwd"}.get(key, default)
1918
return ctx
2019

@@ -48,8 +47,8 @@ def test_execute_command_step_success(mock_context, mock_popen):
4847
assert isinstance(result, Success)
4948
assert result.message == "Command 'echo hello' executed successfully."
5049
assert "hello\n" == result.metadata["command_output"]
51-
mock_context.ui.text.info.assert_called_with("Executing command: echo hello")
52-
mock_context.ui.text.body.assert_called_with("hello\n")
50+
mock_context.textual.text.assert_any_call("Executing command: echo hello")
51+
mock_context.textual.text.assert_any_call("hello\n")
5352
mock_popen.assert_called_once()
5453

5554
def test_execute_command_step_failure(mock_context, mock_popen):
@@ -63,8 +62,8 @@ def test_execute_command_step_failure(mock_context, mock_popen):
6362
assert isinstance(result, Error)
6463
assert "Command failed with exit code 1" in result.message
6564
assert "error stderr" in result.message
66-
mock_context.ui.text.info.assert_called_with("Executing command: exit 1")
67-
mock_context.ui.text.body.assert_called_with("error stdout")
65+
mock_context.textual.text.assert_any_call("Executing command: exit 1")
66+
mock_context.textual.text.assert_any_call("error stdout")
6867
mock_popen.assert_called_once()
6968

7069

@@ -79,7 +78,7 @@ def test_execute_command_step_command_not_found(mock_context, mock_popen):
7978

8079
assert isinstance(result, Error)
8180
assert "Command not found: non_existent_command" in result.message
82-
mock_context.ui.text.info.assert_called_with("Executing command: non_existent_command")
81+
mock_context.textual.text.assert_any_call("Executing command: non_existent_command")
8382

8483
def test_execute_command_step_with_venv(mock_context, mock_get_poetry_venv_env, mock_popen):
8584
"""Tests command execution when use_venv is true."""
@@ -93,8 +92,8 @@ def test_execute_command_step_with_venv(mock_context, mock_get_poetry_venv_env,
9392
assert result.message == "Command 'echo venv_activated' executed successfully."
9493
mock_get_poetry_venv_env.assert_called_once_with(cwd="/tmp/mock_cwd")
9594
# Check that both UI messages were called
96-
assert any(call[0][0] == "Activating poetry virtual environment for step..." for call in mock_context.ui.text.body.call_args_list)
97-
assert any(call[0][0] == "venv_activated\n" for call in mock_context.ui.text.body.call_args_list)
95+
mock_context.textual.dim_text.assert_any_call("Activating poetry virtual environment for step...")
96+
mock_context.textual.text.assert_any_call("venv_activated\n")
9897
mock_popen.assert_called_once()
9998
assert mock_popen.call_args[1]['env'] == mock_get_poetry_venv_env.return_value # Check env is passed
10099

@@ -106,7 +105,7 @@ def test_execute_command_step_venv_not_found(mock_context, mock_get_poetry_venv_
106105

107106
assert isinstance(result, Error)
108107
assert "Could not determine poetry virtual environment." in result.message
109-
mock_context.ui.text.body.assert_called_with("Activating poetry virtual environment for step...", style="dim")
108+
mock_context.textual.dim_text.assert_called_with("Activating poetry virtual environment for step...")
110109
mock_popen.assert_not_called() # Popen should not be called if venv not found
111110

112111
def test_execute_command_step_parameter_substitution(mock_context, mock_popen):
@@ -121,8 +120,8 @@ def test_execute_command_step_parameter_substitution(mock_context, mock_popen):
121120
assert isinstance(result, Success)
122121
assert result.message == "Command 'echo substituted_value' executed successfully."
123122
assert "substituted_value\n" == result.metadata["command_output"]
124-
mock_context.ui.text.info.assert_called_with("Executing command: echo substituted_value")
125-
mock_context.ui.text.body.assert_called_with("substituted_value\n")
123+
mock_context.textual.text.assert_any_call("Executing command: echo substituted_value")
124+
mock_context.textual.text.assert_any_call("substituted_value\n")
126125

127126
def test_execute_command_step_no_command_template(mock_context, mock_popen):
128127
"""Tests when command attribute is empty."""

0 commit comments

Comments
 (0)