Skip to content

Commit f18f226

Browse files
authored
feat: Implement isolated plugin runtimes and project-scoped source management (#207)
1 parent 75f7551 commit f18f226

19 files changed

+1382
-931
lines changed

.claude/docs/community-plugins.md

Lines changed: 91 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -4,238 +4,176 @@
44

55
Titan supports community plugins in addition to the official plugins bundled with the CLI.
66

7-
There are currently two source channels:
7+
There are two source channels:
88

9-
- `stable`: install from a git repository at an explicit tag or commit
10-
- `dev_local`: use a local plugin checkout during development
9+
- `stable`: a shared project pin stored in `.titan/config.toml`
10+
- `dev_local`: a user-local override stored in `~/.titan/config.toml`
1111

12-
Community plugin installs are tracked globally in `~/.titan/community_plugins.toml`, while the active source selection for a given project is stored in that project's `.titan/config.toml`.
12+
Titan itself can remain globally installed, while project-pinned community plugins are prepared in isolated local runtimes.
1313

1414
---
1515

1616
## Key Files
1717

1818
| File | Role |
1919
|------|------|
20-
| `titan_cli/core/plugins/community.py` | Business logic for URL parsing, metadata preview, stable/dev-local install helpers, updates, uninstall, tracking file I/O |
21-
| `titan_cli/core/plugins/models.py` | Plugin source config model (`source.channel`, `source.path`) |
22-
| `titan_cli/core/plugins/plugin_registry.py` | Applies `dev_local` source overrides before plugin initialization |
23-
| `titan_cli/ui/tui/screens/install_plugin_screen.py` | Stable community plugin install wizard |
24-
| `titan_cli/ui/tui/screens/plugin_management.py` | Source display, update/uninstall actions, stable reset for `dev_local` |
25-
| `titan_cli/ui/tui/widgets/wizard.py` | Shared wizard widgets: `StepStatus`, `WizardStep`, `StepIndicator` |
20+
| `titan_cli/core/plugins/community_sources.py` | URL parsing, metadata preview, ref resolution, update checks |
21+
| `titan_cli/core/plugins/runtime.py` | Isolated runtime/cache manager for `stable` community plugins |
22+
| `titan_cli/core/plugins/models.py` | Plugin source config model |
23+
| `titan_cli/core/plugins/plugin_registry.py` | Resolves effective source and loads `dev_local` or cached `stable` plugin code |
24+
| `titan_cli/ui/tui/screens/install_plugin_screen.py` | Adds a stable community plugin to the current project |
25+
| `titan_cli/ui/tui/screens/plugin_management.py` | Displays source state and handles update/remove/dev override actions |
2626

2727
---
2828

29-
## Channels
29+
## Source Model
3030

31-
### `stable`
31+
### Shared project pin (`stable`)
3232

33-
This is the normal community-plugin flow. The user installs from a git repository URL and must include an explicit version selector:
34-
35-
```text
36-
https://github.com/user/titan-plugin-custom@v1.2.0
37-
https://github.com/user/titan-plugin-custom@abc123def456
38-
```
39-
40-
Titan resolves that ref to a concrete commit SHA and installs from that pinned revision. After install, the current project stores:
33+
The shared stable source lives in `.titan/config.toml`:
4134

4235
```toml
4336
[plugins.custom]
4437
enabled = true
4538

4639
[plugins.custom.source]
4740
channel = "stable"
41+
repo_url = "https://github.com/user/titan-plugin-custom"
42+
requested_ref = "v1.2.0"
43+
resolved_commit = "0123456789abcdef0123456789abcdef01234567"
4844
```
4945

50-
For update detection, Titan checks the repository's latest GitHub Release and
51-
uses its `tag_name` as the next requested stable version. In other words:
52-
- install/update discovery is version/tag based
53-
- the actual installed artifact is still pinned to the resolved commit SHA
54-
55-
### `dev_local`
46+
Notes:
47+
- `requested_ref` stores the exact tag/ref used by that repository
48+
- `resolved_commit` is the operational truth
49+
- this block is meant to be committed and reviewed in PRs
5650

57-
This is the development channel. It is not a remote dev feed or prerelease registry. It means "load this plugin from a local repository path".
51+
### User-local override (`dev_local`)
5852

59-
The current project stores:
53+
The active local development override lives in `~/.titan/config.toml`:
6054

6155
```toml
62-
[plugins.custom]
63-
enabled = true
64-
6556
[plugins.custom.source]
6657
channel = "dev_local"
6758
path = "/absolute/path/to/local/plugin/repo"
6859
```
6960

70-
When `dev_local` is active, the plugin registry loads the plugin directly from that repo:
71-
72-
1. Reads `pyproject.toml`
73-
2. Parses the `titan.plugins` entry points
74-
3. Finds the requested Titan plugin name
75-
4. Prepends the repo to `sys.path`
76-
5. Imports and instantiates the plugin class
77-
78-
This override is applied before plugin initialization, so the local checkout wins over any installed stable version for that project.
79-
80-
If `channel = "dev_local"` is set without a `path`, the plugin is marked as failed to load.
61+
Notes:
62+
- this is not committed to the project
63+
- if present, it wins over the project's `stable` pin on that machine
64+
- when switching back to `stable`, the remembered `path` may stay in global config as UX state, but it is ignored
8165

8266
---
8367

84-
## Stable URL Format
68+
## Resolution Rules
8569

86-
Users must always include an explicit version — bare URLs without `@version` are rejected:
70+
Titan resolves the effective source in this order:
8771

88-
```
89-
# Accepted
90-
https://github.com/user/titan-plugin-custom@v1.2.0
91-
https://github.com/user/titan-plugin-custom@abc123def456
72+
1. global `dev_local` override
73+
2. project `stable` pin
9274

93-
# Rejected
94-
https://github.com/user/titan-plugin-custom
95-
```
96-
97-
Internally this becomes: `git+https://github.com/user/titan-plugin-custom.git@v1.2.0`
75+
If neither exists, the plugin is treated as a normal installed plugin with no community source metadata.
9876

9977
---
10078

101-
## Stable Install Flow (4 steps)
79+
## Stable Install Flow
10280

10381
### 1. URL
104-
User enters `https://repo@version`. Validated with `validate_url()` before advancing.
105-
106-
### 2. Preview
107-
Fetches `pyproject.toml` from the repo at that exact version and shows:
108-
- Package name, version, description, authors
109-
- Titan entry points registered (`[titan.plugins]`)
110-
- Python dependencies
11182

112-
**Host detection** (`PluginHost` StrEnum): `GITHUB`, `GITLAB`, `BITBUCKET`, `UNKNOWN`
83+
The user enters a URL with an explicit ref:
11384

114-
Raw URL patterns per host:
115-
```
116-
GitHub: https://raw.githubusercontent.com/{path}/{version}/pyproject.toml
117-
GitLab: https://gitlab.com/{path}/-/raw/{version}/pyproject.toml
118-
Bitbucket: https://bitbucket.org/{path}/raw/{version}/pyproject.toml
119-
Unknown: fetch skipped — warning shown, install still allowed
85+
```text
86+
https://github.com/user/titan-plugin-custom@v1.2.0
87+
https://github.com/user/titan-plugin-custom@abc123def456
12088
```
12189

122-
Error cases (all allow proceeding):
123-
- HTTP 404 → URL or version not found
124-
- Network error → connection problem
125-
- No `[titan.plugins]` entry point → warns plugin won't be visible in Titan
126-
- Unparseable pyproject.toml → warns metadata unreadable
90+
Bare repository URLs without `@ref` are rejected.
12791

128-
A **security warning** is always shown regardless of outcome.
129-
130-
### 3. Install
131-
Runs a package install in Titan's active Python environment:
92+
### 2. Preview
13293

133-
- pipx environment: `pipx inject titan-cli git+<url>.git@<resolved_commit>`
134-
- non-pipx environment: `python -m pip install git+<url>.git@<resolved_commit>`
94+
Titan fetches `pyproject.toml` from that source and shows:
95+
- package name
96+
- version
97+
- description
98+
- authors
99+
- Titan entry points
100+
- Python dependencies
135101

136-
The requested tag or short ref is resolved to a full commit SHA first. This is the security anchor for stable installs: updates only happen when the user explicitly installs or updates again.
102+
### 3. Pin + runtime
137103

138-
On success:
139-
1. Saves record to `~/.titan/community_plugins.toml`
140-
2. Writes the project source override as `channel = "stable"`
141-
3. Calls `self.config.load()` → auto-reloads registry + re-initializes all plugins (no restart needed)
104+
Titan resolves the requested ref to a full commit SHA and then:
142105

143-
On failure: shows pipx stderr + actionable suggestions.
106+
1. writes the shared stable pin into the current project's `.titan/config.toml`
107+
2. prepares an isolated runtime for that plugin commit
108+
3. reloads config/registry so the plugin becomes available immediately
144109

145110
### 4. Done
146-
Success or failure summary. "Finish" dismisses the wizard.
147111

148-
---
112+
The wizard shows the pinned ref/commit and the Titan plugin name if found.
149113

150-
## Tracking File
114+
---
151115

152-
`~/.titan/community_plugins.toml` is global and stores installed/tracked community plugin records. It is not the same as project source selection.
116+
## Runtime Layout
153117

154-
Current stable records look like this:
118+
Stable community plugins are prepared in a cache like:
155119

156-
```toml
157-
[[plugins]]
158-
repo_url = "https://github.com/user/titan-plugin-custom"
159-
package_name = "titan-plugin-custom"
160-
titan_plugin_name = "custom"
161-
installed_at = "2026-03-06T10:30:00+00:00"
162-
channel = "stable"
163-
requested_ref = "v1.2.0"
164-
resolved_commit = "0123456789abcdef0123456789abcdef01234567"
165-
```
166-
167-
`dev_local` records use the same structure but store the local path instead of git revision metadata:
168-
169-
```toml
170-
[[plugins]]
171-
repo_url = ""
172-
package_name = "titan-plugin-custom"
173-
titan_plugin_name = "custom"
174-
installed_at = "2026-03-06T10:30:00+00:00"
175-
channel = "dev_local"
176-
dev_local_path = "/absolute/path/to/local/plugin/repo"
120+
```text
121+
~/.titan/plugin-cache/<plugin_name>/<resolved_commit>/
122+
src/
123+
venv/
177124
```
178125

179-
Key functions in `community.py`:
180-
- `load_community_plugins()``list[CommunityPluginRecord]`
181-
- `save_community_plugin(record)` → appends to file
182-
- `remove_community_plugin_by_name(titan_plugin_name)` → removes all tracked channels for a Titan plugin name
183-
- `remove_community_plugin_by_channel(titan_plugin_name, channel)` → removes one tracked channel only
184-
- `get_community_plugin_names()``set[str]` of titan_plugin_names (used for `[community]` badge)
185-
- `get_community_plugin_by_titan_name(name)``Optional[CommunityPluginRecord]`
186-
- `get_community_plugin_by_name_and_channel(name, channel)` → specific channel lookup
187-
188-
---
126+
The runtime manager:
189127

190-
## Uninstall Flow
128+
1. checks out the pinned commit into `src/`
129+
2. creates a dedicated `venv/`
130+
3. installs the plugin into that isolated environment
191131

192-
In Plugin Management, pressing `u` (or clicking "Uninstall") on a community plugin:
193-
1. If the active source is `dev_local`, Titan does not uninstall a package. It only resets the project source override back to `stable` and removes `path`.
194-
2. If the active source is a tracked stable community plugin, Titan uninstalls the package from the active environment.
195-
3. Removes the tracked record(s) as needed.
196-
4. Calls `self.config.load()` to reload the registry.
197-
5. Refreshes the plugin list.
132+
The plugin registry then loads the plugin from:
133+
- the cached source directory
134+
- the cached `site-packages`
198135

199-
Only stable community plugins can be updated. `dev_local` intentionally has no update flow.
136+
This gives dependency isolation per `plugin + commit` while still using the current in-process plugin API.
200137

201138
---
202139

203-
## Dev-Local Install Helper
140+
## Update Flow
141+
142+
Only `stable` community plugins can be updated.
204143

205-
The codebase includes `install_community_plugin_dev_local(local_path)`, which performs an editable install:
144+
Update behavior:
206145

207-
- pipx environment: `pipx runpip titan-cli install -e <path>`
208-
- non-pipx environment: `python -m pip install -e <path>`
146+
1. check latest release/tag from the repo host
147+
2. resolve that ref to a full SHA
148+
3. update the current project's `.titan/config.toml`
149+
4. prepare the runtime for the new commit
150+
5. reload Titan config/registry
209151

210-
This is useful for plugin development, but the current TUI install wizard is specifically built around the `stable` git URL flow. The active `dev_local` behavior is primarily driven by project config source overrides.
152+
`dev_local` has no update flow by design.
211153

212154
---
213155

214-
## Wizard Widgets (`titan_cli/ui/tui/widgets/wizard.py`)
156+
## Remove Flow
215157

216-
Shared by `install_plugin_screen.py` and `plugin_config_wizard.py`:
158+
In Plugin Management:
217159

218-
```python
219-
class StepStatus(StrEnum):
220-
PENDING = "pending"
221-
IN_PROGRESS = "in_progress"
222-
COMPLETED = "completed"
160+
- if the active source is `dev_local`, remove the local override from global config
161+
- if the active source is `stable`, remove the plugin from the current project's config
223162

224-
@dataclass
225-
class WizardStep:
226-
id: str
227-
title: str
163+
This no longer uninstalls a package from Titan's global environment, because `stable` community plugins are no longer managed with `pipx inject`.
228164

229-
class StepIndicator(Static):
230-
def __init__(self, step_number: int, step: WizardStep, status: StepStatus): ...
231-
```
165+
---
232166

233-
Exported from `titan_cli/ui/tui/widgets/__init__.py`.
167+
## Important Notes
234168

235-
---
169+
- `~/.titan/community_plugins.toml` is no longer used
170+
- `community.py` was replaced by `community_sources.py`
171+
- official plugins can still follow their own global install path; the per-project runtime model here is specifically for community plugins
236172

237-
## Known Technical Debt
173+
---
238174

239-
`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.
175+
## Known Limits
240176

241-
The public docs are still sparse on community plugins, and some older references still describe only the stable install flow. When updating docs, treat `dev_local` as a first-class source channel and avoid describing it as a remote development release channel.
177+
- community plugins still run in-process after being imported
178+
- dependency isolation is per plugin runtime, but execution is not sandboxed in a subprocess
179+
- a future architecture could move plugin execution out-of-process if stronger isolation is needed

.titan/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ pr_template_path = ".github/pull_request_template.md"
2020
auto_assign_prs = true
2121

2222
[plugins.jira]
23-
enabled = false
23+
enabled = false

docs/concepts/plugins.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,32 @@ Titan also supports community plugins from external repositories.
2828

2929
There are currently two source channels:
3030

31-
- `stable`: install a plugin from a git repository pinned to a tag or commit
31+
- `stable`: pin a plugin version in the project config using a git tag or commit
3232
- `dev_local`: use a local checkout of a plugin repository during development
3333

34-
Project source selection is stored in `.titan/config.toml`:
34+
The shared stable pin lives in `.titan/config.toml`:
3535

3636
```toml
3737
[plugins.custom]
3838
enabled = true
3939

4040
[plugins.custom.source]
4141
channel = "stable"
42+
repo_url = "https://github.com/user/titan-plugin-custom"
43+
requested_ref = "v1.2.0"
44+
resolved_commit = "0123456789abcdef0123456789abcdef01234567"
4245
```
4346

44-
For local plugin development:
47+
`requested_ref` stores the exact tag or ref used by that repository. Some repos use
48+
tags like `v1.2.0`; others use `1.2.0`.
4549

46-
```toml
47-
[plugins.custom]
48-
enabled = true
50+
For local plugin development, the active override lives in `~/.titan/config.toml`:
4951

52+
```toml
5053
[plugins.custom.source]
5154
channel = "dev_local"
5255
path = "/absolute/path/to/local/plugin/repo"
5356
```
5457

55-
In `dev_local`, Titan loads the plugin directly from the local repository by reading its `pyproject.toml` and `titan.plugins` entry point.
56-
57-
Community plugin installation metadata is tracked globally in `~/.titan/community_plugins.toml`.
58+
In `dev_local`, Titan loads the plugin directly from the local repository. In `stable`,
59+
Titan prepares an isolated local runtime for the pinned commit.

0 commit comments

Comments
 (0)