|
4 | 4 |
|
5 | 5 | Titan supports community plugins in addition to the official plugins bundled with the CLI. |
6 | 6 |
|
7 | | -There are currently two source channels: |
| 7 | +There are two source channels: |
8 | 8 |
|
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` |
11 | 11 |
|
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. |
13 | 13 |
|
14 | 14 | --- |
15 | 15 |
|
16 | 16 | ## Key Files |
17 | 17 |
|
18 | 18 | | File | Role | |
19 | 19 | |------|------| |
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 | |
26 | 26 |
|
27 | 27 | --- |
28 | 28 |
|
29 | | -## Channels |
| 29 | +## Source Model |
30 | 30 |
|
31 | | -### `stable` |
| 31 | +### Shared project pin (`stable`) |
32 | 32 |
|
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`: |
41 | 34 |
|
42 | 35 | ```toml |
43 | 36 | [plugins.custom] |
44 | 37 | enabled = true |
45 | 38 |
|
46 | 39 | [plugins.custom.source] |
47 | 40 | channel = "stable" |
| 41 | +repo_url = "https://github.com/user/titan-plugin-custom" |
| 42 | +requested_ref = "v1.2.0" |
| 43 | +resolved_commit = "0123456789abcdef0123456789abcdef01234567" |
48 | 44 | ``` |
49 | 45 |
|
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 |
56 | 50 |
|
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`) |
58 | 52 |
|
59 | | -The current project stores: |
| 53 | +The active local development override lives in `~/.titan/config.toml`: |
60 | 54 |
|
61 | 55 | ```toml |
62 | | -[plugins.custom] |
63 | | -enabled = true |
64 | | - |
65 | 56 | [plugins.custom.source] |
66 | 57 | channel = "dev_local" |
67 | 58 | path = "/absolute/path/to/local/plugin/repo" |
68 | 59 | ``` |
69 | 60 |
|
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 |
81 | 65 |
|
82 | 66 | --- |
83 | 67 |
|
84 | | -## Stable URL Format |
| 68 | +## Resolution Rules |
85 | 69 |
|
86 | | -Users must always include an explicit version — bare URLs without `@version` are rejected: |
| 70 | +Titan resolves the effective source in this order: |
87 | 71 |
|
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 |
92 | 74 |
|
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. |
98 | 76 |
|
99 | 77 | --- |
100 | 78 |
|
101 | | -## Stable Install Flow (4 steps) |
| 79 | +## Stable Install Flow |
102 | 80 |
|
103 | 81 | ### 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 |
111 | 82 |
|
112 | | -**Host detection** (`PluginHost` StrEnum): `GITHUB`, `GITLAB`, `BITBUCKET`, `UNKNOWN` |
| 83 | +The user enters a URL with an explicit ref: |
113 | 84 |
|
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 |
120 | 88 | ``` |
121 | 89 |
|
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. |
127 | 91 |
|
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 |
132 | 93 |
|
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 |
135 | 101 |
|
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 |
137 | 103 |
|
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: |
142 | 105 |
|
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 |
144 | 109 |
|
145 | 110 | ### 4. Done |
146 | | -Success or failure summary. "Finish" dismisses the wizard. |
147 | 111 |
|
148 | | ---- |
| 112 | +The wizard shows the pinned ref/commit and the Titan plugin name if found. |
149 | 113 |
|
150 | | -## Tracking File |
| 114 | +--- |
151 | 115 |
|
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 |
153 | 117 |
|
154 | | -Current stable records look like this: |
| 118 | +Stable community plugins are prepared in a cache like: |
155 | 119 |
|
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/ |
177 | 124 | ``` |
178 | 125 |
|
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: |
189 | 127 |
|
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 |
191 | 131 |
|
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` |
198 | 135 |
|
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. |
200 | 137 |
|
201 | 138 | --- |
202 | 139 |
|
203 | | -## Dev-Local Install Helper |
| 140 | +## Update Flow |
| 141 | + |
| 142 | +Only `stable` community plugins can be updated. |
204 | 143 |
|
205 | | -The codebase includes `install_community_plugin_dev_local(local_path)`, which performs an editable install: |
| 144 | +Update behavior: |
206 | 145 |
|
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 |
209 | 151 |
|
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. |
211 | 153 |
|
212 | 154 | --- |
213 | 155 |
|
214 | | -## Wizard Widgets (`titan_cli/ui/tui/widgets/wizard.py`) |
| 156 | +## Remove Flow |
215 | 157 |
|
216 | | -Shared by `install_plugin_screen.py` and `plugin_config_wizard.py`: |
| 158 | +In Plugin Management: |
217 | 159 |
|
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 |
223 | 162 |
|
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`. |
228 | 164 |
|
229 | | -class StepIndicator(Static): |
230 | | - def __init__(self, step_number: int, step: WizardStep, status: StepStatus): ... |
231 | | -``` |
| 165 | +--- |
232 | 166 |
|
233 | | -Exported from `titan_cli/ui/tui/widgets/__init__.py`. |
| 167 | +## Important Notes |
234 | 168 |
|
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 |
236 | 172 |
|
237 | | -## Known Technical Debt |
| 173 | +--- |
238 | 174 |
|
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 |
240 | 176 |
|
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 |
0 commit comments