Skip to content

Commit bb9f42f

Browse files
takemi-ohamaclaude
andcommitted
feat: PLAN31_2-plugin-ops plugin 全操作 + repo の TUI 追加
- tui/actions_plugin.py 新設: plugin list/install/uninstall/update/info/ sync/migrate と repo add/remove/list/refresh のサブ階層メニューを実装。 引数は menu.* で収集し dispatch_group 経由で cmd_plugin へ委譲 (plan 2.3 の属性契約は cli.py parser と突き合わせて検証済み・乖離なし) - uninstall/update/info と repo remove/refresh の name は registry (plugins.yml) の一覧から選択。破壊的な uninstall / repo remove は menu.confirm で実行前確認 (plan 3.4) - tui/app.py の _route に plugin を配線 (未実装プレースホルダ案内を解消) - tests/cli/tui/test_actions_plugin.py 新設 + test_app.py の routing 検証更新 (597 passed / 1 skipped・退行なし) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 443c026 commit bb9f42f

4 files changed

Lines changed: 913 additions & 6 deletions

File tree

lib/devbase/tui/actions_plugin.py

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
"""plugin カテゴリの TUI 操作フロー (PLAN31_2 PR4)。
2+
3+
``devbase plugin`` の全サブコマンド (list/install/uninstall/update/info/sync/migrate)
4+
と ``plugin repo`` のサブ階層 (add/remove/list/refresh) を TUI から実行できるようにする。
5+
引数は ``tui.menu`` の収集ヘルパで CLI parser と同じ属性値 (plan 2.3 契約表) を集め、
6+
``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_plugin`` へ委譲する
7+
(ロジック二重実装なし)。
8+
9+
uninstall/update/info および repo remove/refresh の ``name`` は、registry
10+
(``plugins.yml``) から取得した導入済み plugin / 登録済みリポジトリの一覧から
11+
選択させる (自由入力によるタイプミスを防ぐ)。破壊的な uninstall / repo remove は
12+
``menu.confirm`` で実行前確認する (plan 3.4)。
13+
14+
ナビ規約 (actions_project と同一):
15+
- Esc / ← = 1 つ前のメニューへ戻る (``menu.MENU_BACK``)
16+
- Ctrl-C = 全体中止 (``None`` を伝搬)
17+
- 引数収集の中止 (``_ARG_CANCEL``) = 直前のサブメニューを再表示
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from pathlib import Path
23+
24+
from devbase.errors import DevbaseError
25+
from devbase.log import get_logger
26+
from devbase.tui import menu
27+
from devbase.tui.dispatch import dispatch_group
28+
29+
logger = get_logger(__name__)
30+
31+
# plugin サブコマンド (表示順 = ハイライト既定順)。閲覧系の list を先頭に置き、
32+
# Enter 連打で安全な一覧表示へ到達できるようにする。value は cmd_plugin の subcommand 名
33+
# (repo のみサブ階層メニューへの分岐)。
34+
_PLUGIN_OPS: list[tuple[str, str]] = [
35+
("一覧表示 (list)", "list"),
36+
("インストール (install)", "install"),
37+
("アンインストール (uninstall)", "uninstall"),
38+
("更新 (update)", "update"),
39+
("詳細表示 (info)", "info"),
40+
("プロジェクトリンク再同期 (sync)", "sync"),
41+
("レガシー構成の移行 (migrate)", "migrate"),
42+
("リポジトリ管理 (repo)", "repo"),
43+
]
44+
45+
# plugin repo サブ階層 (表示順 = ハイライト既定順)。value は repo_command 名。
46+
_REPO_OPS: list[tuple[str, str]] = [
47+
("リポジトリ一覧 (list)", "list"),
48+
("リポジトリ登録 (add)", "add"),
49+
("リポジトリ削除 (remove)", "remove"),
50+
("リポジトリ更新 (refresh)", "refresh"),
51+
]
52+
53+
# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。
54+
# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。
55+
_ARG_CANCEL = object()
56+
57+
58+
def _dispatch(devbase_root: Path, subcommand: str, **attrs) -> int:
59+
"""``cmd_plugin`` へ委譲する (plan 2.3 の属性契約は呼び出し側が守る)。
60+
61+
import を呼び出し時まで遅延させ、テストが ``commands.plugin.cmd_plugin`` を
62+
monkeypatch で差し替えられるようにする (dispatch_lifecycle と同じ流儀)。
63+
"""
64+
from devbase.commands.plugin import cmd_plugin
65+
66+
return dispatch_group(cmd_plugin, devbase_root, subcommand, **attrs)
67+
68+
69+
# ---------------------------------------------------------------------------
70+
# 名前選択 (registry から一覧を取得して選ばせる)
71+
# ---------------------------------------------------------------------------
72+
73+
def _installed_plugin_names(devbase_root: Path) -> list[str]:
74+
"""導入済み plugin 名の一覧を registry (plugins.yml) から取得する。"""
75+
from devbase.plugin.registry import PluginRegistry
76+
77+
try:
78+
return [p.name for p in PluginRegistry(Path(devbase_root)).list_installed()]
79+
except DevbaseError as e:
80+
logger.error("%s", e)
81+
return []
82+
83+
84+
def _repository_names(devbase_root: Path) -> list[str]:
85+
"""登録済みリポジトリ名の一覧を registry (plugins.yml) から取得する。"""
86+
from devbase.plugin.registry import PluginRegistry
87+
88+
try:
89+
return [r.name for r in PluginRegistry(Path(devbase_root)).list_repositories()]
90+
except DevbaseError as e:
91+
logger.error("%s", e)
92+
return []
93+
94+
95+
def _select_name(message: str, names: list[str], *, all_label: str | None = None):
96+
"""名前一覧から 1 件選ばせる共通ヘルパ。
97+
98+
``all_label`` 指定時は「全対象」(value="") を先頭に置く。選択メニューの ``None``
99+
(Ctrl-C) と衝突させないため空文字を番兵にし、呼び出し側へは ``None`` に変換して
100+
返す (_select_build_image と同じ流儀)。
101+
102+
戻り値: 名前 (``str``) / ``None`` (all_label 選択 = 全対象) / ``_ARG_CANCEL``
103+
(Esc・←・Ctrl-C 中止)。
104+
"""
105+
choices: list[tuple[str, str]] = []
106+
if all_label is not None:
107+
choices.append((all_label, ""))
108+
choices += [(n, n) for n in names]
109+
sel = menu.select(
110+
f"{message} (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):",
111+
choices, back=True, search=False)
112+
if sel is menu.MENU_BACK or sel is None:
113+
return _ARG_CANCEL
114+
return sel or None # "" → None (全対象)
115+
116+
117+
def _select_installed_plugin(devbase_root: Path, message: str, *,
118+
all_label: str | None = None):
119+
"""導入済み plugin から 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。"""
120+
names = _installed_plugin_names(devbase_root)
121+
if not names:
122+
logger.info("導入済みの plugin がありません。"
123+
"`plugin install` で導入してください。")
124+
return _ARG_CANCEL
125+
return _select_name(message, names, all_label=all_label)
126+
127+
128+
def _select_repository(devbase_root: Path, message: str, *,
129+
all_label: str | None = None):
130+
"""登録済みリポジトリから 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。"""
131+
names = _repository_names(devbase_root)
132+
if not names:
133+
logger.info("登録済みのリポジトリがありません。"
134+
"`plugin repo add` で登録してください。")
135+
return _ARG_CANCEL
136+
return _select_name(message, names, all_label=all_label)
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# サブメニュー
141+
# ---------------------------------------------------------------------------
142+
143+
def _select_operation():
144+
"""plugin 操作を選ぶサブメニュー。
145+
146+
戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None``
147+
(Ctrl-C 中止)。
148+
"""
149+
return menu.select(
150+
"plugin 操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):",
151+
list(_PLUGIN_OPS), back=True, search=False)
152+
153+
154+
def _select_repo_operation():
155+
"""plugin repo 操作を選ぶサブ階層メニュー。
156+
157+
戻り値: repo_command 文字列 / ``MENU_BACK`` (Esc・← → plugin メニューへ戻る) /
158+
``None`` (Ctrl-C 中止)。
159+
"""
160+
return menu.select(
161+
"リポジトリ操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):",
162+
list(_REPO_OPS), back=True, search=False)
163+
164+
165+
# ---------------------------------------------------------------------------
166+
# 各操作の引数収集 + dispatch (plan 2.3 契約)
167+
# ---------------------------------------------------------------------------
168+
169+
def _run_operation(devbase_root: Path, op: str):
170+
"""選択された plugin 操作の引数を収集して ``cmd_plugin`` へ委譲する。
171+
172+
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 =
173+
サブメニューへ戻る)。破壊的な uninstall は ``menu.confirm`` で確認する (plan 3.4)。
174+
"""
175+
if op == "list":
176+
# --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。
177+
available = menu.confirm(
178+
"未導入の利用可能 plugin を表示しますか (--available)?", default=False)
179+
if available is None:
180+
return _ARG_CANCEL
181+
return _dispatch(devbase_root, "list", available=available)
182+
183+
if op == "install":
184+
source = menu.text(
185+
"インストールする plugin の source (名前 / URL / パス)",
186+
allow_empty=False)
187+
if source is None:
188+
return _ARG_CANCEL
189+
link = menu.confirm(
190+
"symlink としてインストールしますか (--link)?", default=False)
191+
if link is None:
192+
return _ARG_CANCEL
193+
install_all = menu.confirm(
194+
"リポジトリ内の全 plugin をインストールしますか (--all)?", default=False)
195+
if install_all is None:
196+
return _ARG_CANCEL
197+
return _dispatch(devbase_root, "install",
198+
source=source, link=link, install_all=install_all)
199+
200+
if op == "uninstall":
201+
name = _select_installed_plugin(
202+
devbase_root, "アンインストールする plugin を選択")
203+
if name is _ARG_CANCEL:
204+
return _ARG_CANCEL
205+
ok = menu.confirm(f"plugin '{name}' をアンインストールしますか?", default=False)
206+
if not ok: # False (拒否) / None (中止) → 実行しない
207+
return _ARG_CANCEL
208+
return _dispatch(devbase_root, "uninstall", name=name)
209+
210+
if op == "update":
211+
# name=None で全 plugin 更新 (CLI の `plugin update` 引数省略と同じ)。
212+
name = _select_installed_plugin(
213+
devbase_root, "更新する plugin を選択",
214+
all_label="全 plugin を更新")
215+
if name is _ARG_CANCEL:
216+
return _ARG_CANCEL
217+
return _dispatch(devbase_root, "update", name=name)
218+
219+
if op == "info":
220+
name = _select_installed_plugin(
221+
devbase_root, "詳細を表示する plugin を選択")
222+
if name is _ARG_CANCEL:
223+
return _ARG_CANCEL
224+
return _dispatch(devbase_root, "info", name=name)
225+
226+
if op in ("sync", "migrate"):
227+
# 引数なし (plan 2.3: sync/migrate は属性なし)。即実行。
228+
return _dispatch(devbase_root, op)
229+
230+
# 到達しない (メニュー値は _PLUGIN_OPS に限定される)。保守的に no-op。
231+
logger.error("未知の操作です: %s", op)
232+
return _ARG_CANCEL
233+
234+
235+
def _run_repo_operation(devbase_root: Path, op: str):
236+
"""選択された plugin repo 操作の引数を収集して ``cmd_plugin`` へ委譲する。
237+
238+
repo 系は ``subcommand='repo'`` + ``repo_command=<op>`` の二段属性で
239+
``cmd_repo`` へ分岐する (plan 2.3 契約)。戻り値プロトコルは ``_run_operation``
240+
と同じ。破壊的な remove は ``menu.confirm`` で確認する (plan 3.4)。
241+
"""
242+
if op == "list":
243+
return _dispatch(devbase_root, "repo", repo_command="list")
244+
245+
if op == "add":
246+
url = menu.text(
247+
"登録するリポジトリの URL (GitHub は owner/repo 短縮形も可)",
248+
allow_empty=False)
249+
if url is None:
250+
return _ARG_CANCEL
251+
# --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。
252+
name = menu.text("カスタム名 (--name 空で自動)", allow_empty=True)
253+
if name is None:
254+
return _ARG_CANCEL
255+
return _dispatch(devbase_root, "repo",
256+
repo_command="add", url=url, name=name or None)
257+
258+
if op == "remove":
259+
name = _select_repository(devbase_root, "削除するリポジトリを選択")
260+
if name is _ARG_CANCEL:
261+
return _ARG_CANCEL
262+
ok = menu.confirm(f"リポジトリ '{name}' を削除しますか?", default=False)
263+
if not ok: # False (拒否) / None (中止) → 実行しない
264+
return _ARG_CANCEL
265+
force = menu.confirm(
266+
"未 commit / 未 push の変更があっても強制削除しますか (--force)?",
267+
default=False)
268+
if force is None:
269+
return _ARG_CANCEL
270+
return _dispatch(devbase_root, "repo",
271+
repo_command="remove", name=name, force=force)
272+
273+
if op == "refresh":
274+
# name=None で全リポジトリを refresh (CLI の引数省略と同じ)。
275+
name = _select_repository(
276+
devbase_root, "更新するリポジトリを選択",
277+
all_label="全リポジトリを更新")
278+
if name is _ARG_CANCEL:
279+
return _ARG_CANCEL
280+
return _dispatch(devbase_root, "repo", repo_command="refresh", name=name)
281+
282+
# 到達しない (メニュー値は _REPO_OPS に限定される)。保守的に no-op。
283+
logger.error("未知のリポジトリ操作です: %s", op)
284+
return _ARG_CANCEL
285+
286+
287+
# ---------------------------------------------------------------------------
288+
# メニューループ
289+
# ---------------------------------------------------------------------------
290+
291+
def _repo_menu(devbase_root: Path):
292+
"""plugin repo のサブ階層メニューを回す。
293+
294+
戻り値プロトコル (``is`` 同一性判定):
295+
- dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。
296+
- ``menu.MENU_BACK``: Esc/← で plugin メニューへ戻る。
297+
- ``None``: Ctrl-C で全体中止。
298+
299+
引数収集を中止 (``_ARG_CANCEL``) した場合はサブ階層メニューを再表示する。
300+
"""
301+
while True:
302+
op = _select_repo_operation()
303+
if op is menu.MENU_BACK:
304+
return menu.MENU_BACK
305+
if op is None:
306+
return None
307+
rc = _run_repo_operation(devbase_root, op)
308+
if rc is _ARG_CANCEL:
309+
continue # 引数収集を中止 → サブ階層メニューへ戻る
310+
return rc # 実行 rc → 呼び出し元へ
311+
312+
313+
def run(devbase_root: Path):
314+
"""プラグイン操作カテゴリ。操作選択 → 引数収集 → ``cmd_plugin`` へ委譲。
315+
316+
戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ):
317+
- **操作を実行した場合**: dispatch の rc (``int``) を返す。失敗 (非0) は
318+
``devbase list`` の終了コードへ伝搬する。
319+
- ``menu.MENU_BACK``: 操作なしでトップメニューへ戻る (Esc/←)。
320+
- ``None``: Ctrl-C による全体中止。
321+
322+
repo はサブ階層メニュー (``_repo_menu``) へ分岐し、Esc/← で plugin メニューへ
323+
戻れる。引数収集を中止した場合は plugin メニューを再表示する。
324+
操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。
325+
"""
326+
while True:
327+
op = _select_operation()
328+
if op is menu.MENU_BACK:
329+
return menu.MENU_BACK
330+
if op is None:
331+
return None
332+
333+
if op == "repo":
334+
rc = _repo_menu(devbase_root)
335+
if rc is menu.MENU_BACK:
336+
continue # plugin メニューへ戻る
337+
return rc # 実行 rc (int) / None (Ctrl-C) を伝搬
338+
rc = _run_operation(devbase_root, op)
339+
if rc is _ARG_CANCEL:
340+
continue # 引数収集を中止 → plugin メニューへ戻る
341+
342+
# 操作完了 → トップメニューへ復帰。rc は呼び出し側 (top loop) が記憶し
343+
# 最終的な devbase の終了コードへ伝搬させる。
344+
return rc

lib/devbase/tui/app.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ
55
(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。
66
7-
PR1 では **project カテゴリのみ配線**し、env/plugin/snapshot/status は後続 PR
8-
(PR3PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。
7+
PR1 project、PR4 で plugin カテゴリを配線済み。env/snapshot/status は後続 PR
8+
(PR3/PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。
99
1010
後方互換 (plan 3.2):
1111
- ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧
@@ -26,7 +26,7 @@
2626

2727
from devbase.commands.project import _print_table, list_projects
2828
from devbase.log import get_logger
29-
from devbase.tui import actions_project, menu
29+
from devbase.tui import actions_plugin, actions_project, menu
3030

3131
logger = get_logger(__name__)
3232

@@ -56,7 +56,9 @@ def _route(category: str, devbase_root: Path):
5656
"""
5757
if category == "project":
5858
return actions_project.run(devbase_root)
59-
# PR3: env, PR4: plugin, PR5: snapshot/status をここに追加する。
59+
if category == "plugin":
60+
return actions_plugin.run(devbase_root)
61+
# PR3: env, PR5: snapshot/status をここに追加する。
6062
logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category))
6163
return menu.MENU_BACK
6264

0 commit comments

Comments
 (0)