diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e571e..b85adc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### Added - **`devbase project` サブコマンド群を新設**しました (PLAN06)。CWD に依存せずプロジェクト名でコンテナ操作ができます。 - `devbase project up/down/ps/logs/scale [name]` で、任意のディレクトリから `$DEVBASE_ROOT/projects/` を対象に操作できます。名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行するため、シェル実装の `build` を含む全操作が名前指定で成立します(呼び出し元シェルの作業ディレクトリは変わりません)。存在しない名前はエラーになり候補が提示されます。 - - `devbase project list [--interactive|-i]` で `$DEVBASE_ROOT/projects/` 配下を `NAME` / `PLUGIN` / `STATUS` の一覧表示します。`PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix(例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します。`--interactive` では一覧から番号で選択して起動でき、非対話環境では番号入力にフォールバックします。 + - `devbase project list` で `$DEVBASE_ROOT/projects/` 配下を `NAME` / `PLUGIN` / `STATUS` の一覧表示します。`PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix(例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します。**TTY ではデフォルトで対話選択**になり、一覧から番号で選んだプロジェクトを `project up` で起動します。`--no-interactive`(`--plain` / `-P`)で一覧表示のみに切り替えられ、パイプ・リダイレクト・CI などの非 TTY 環境では自動的に一覧表示へフォールバックします(`--interactive` / `-i` は後方互換として引き続き受け付けます)。 - トップレベルシノニム `devbase up/down/ps/scale [name]` / `devbase build [image]` / `devbase login [index]` / `devbase list` を整備しました(`logs` はシノニムを持たず `devbase project logs` のみ)。 - bash / zsh のシェル補完に `project` グループとプロジェクト名補完(`$DEVBASE_ROOT/projects/` 配下を列挙)を追加しました。 - 利用者向けドキュメント [`docs/user/cli-reference.md`](docs/user/cli-reference.md) / [`docs/user/container-operations.md`](docs/user/container-operations.md) を `project` 体系に更新しました。 diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 6398069..9a0f388 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -18,7 +18,7 @@ graph TD D --> D1["up / down / ps / logs / scale [name]"] D --> D3["login [index]"] D --> D4["build [image]"] - D --> D2["list [--interactive]"] + D --> D2["list [--no-interactive]"] E --> E1[init / sync / list / set / get / delete / edit / project] F --> F1[list / install / uninstall / update / info / sync] F --> F2[repo add / repo remove / repo list / repo refresh] @@ -292,21 +292,26 @@ devbase build [image] `$DEVBASE_ROOT/projects/` 配下のプロジェクトを `NAME` / `PLUGIN` / `STATUS` の一覧で 表示します。 +TTY(端末)では**デフォルトで対話選択**になり、番号入力で選んだプロジェクトを +`project up` で起動します。パイプ・リダイレクト・CI などの非 TTY 環境では自動的に +一覧表示のみへフォールバックします。 + ``` -devbase project list [--interactive|-i] -devbase list [--interactive|-i] +devbase project list [--no-interactive|--plain|-P] +devbase list [--no-interactive|--plain|-P] ``` | オプション | 説明 | |-----------|------| -| `--interactive` / `-i` | 一覧から番号で選択し、そのプロジェクトを `project up` で起動 | +| `--no-interactive` / `--plain` / `-P` | 対話選択せず一覧表示のみ | +| `--interactive` / `-i` | (後方互換)対話選択。デフォルトのため通常は不要 | ```bash -# 一覧表示 +# 一覧を表示して番号で選択・起動(TTY デフォルト) devbase list -# 一覧から選んで起動(非対話環境では番号入力にフォールバック) -devbase list -i +# 一覧表示のみ(選択しない) +devbase list --no-interactive ``` 出力例: diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index d15889d..6ec3bb1 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -238,13 +238,17 @@ devbase project logs -f --tail 100 ### プロジェクト一覧 ```bash -# 全プロジェクトを NAME / PLUGIN / STATUS で一覧表示 +# 一覧を表示し、番号で選択して起動(TTY ではこれがデフォルト) devbase list -# 一覧から選択して起動(非対話環境では番号入力にフォールバック) -devbase list -i +# 選択せず NAME / PLUGIN / STATUS の一覧表示のみ +devbase list --no-interactive # --plain / -P も同義 ``` +> TTY(端末)では `devbase list` はデフォルトで対話選択になり、番号入力で +> そのプロジェクトを起動します。パイプ・リダイレクト・CI などの非 TTY 環境では +> 自動的に一覧表示のみにフォールバックします。 + `devbase project ps` が「対象プロジェクト 1 つのコンテナ状態」を表示するのに対し、 `devbase list` は「全プロジェクトの横断一覧」を表示します。 diff --git a/etc/_devbase b/etc/_devbase index 6d9c53e..3f50002 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -150,8 +150,11 @@ _devbase() { ;; list) _arguments \ - '--interactive[Select a project interactively and start it]' \ - '-i[Select a project interactively and start it]' + '--no-interactive[Just print the table without interactive selection]' \ + '--plain[Just print the table without interactive selection]' \ + '-P[Just print the table without interactive selection]' \ + '--interactive[(compat) interactive selection, default]' \ + '-i[(compat) interactive selection, default]' ;; project) case "$words[3]" in @@ -185,8 +188,11 @@ _devbase() { ;; list) _arguments \ - '--interactive[Select a project interactively and start it]' \ - '-i[Select a project interactively and start it]' + '--no-interactive[Just print the table without interactive selection]' \ + '--plain[Just print the table without interactive selection]' \ + '-P[Just print the table without interactive selection]' \ + '--interactive[(compat) interactive selection, default]' \ + '-i[(compat) interactive selection, default]' ;; *) _describe -t project-commands 'project command' project_subcommands diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index bf45245..b76a2a0 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -62,10 +62,10 @@ _devbase_completions() { COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) fi ;; - # list は位置引数を取らず --interactive のみ。`-*` ガードを外し + # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 list) - COMPREPLY=($(compgen -W "--interactive -i" -- "$cur")) + COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) ;; project) COMPREPLY=($(compgen -W "$project_subcommands" -- "$cur")) @@ -121,10 +121,10 @@ _devbase_completions() { COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) fi ;; - # list は位置引数を取らず --interactive のみ。`-*` ガードを外し + # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 list) - COMPREPLY=($(compgen -W "--interactive -i" -- "$cur")) + COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) ;; esac fi diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index d22773e..8dc9182 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -183,11 +183,18 @@ def _add_project_parser(subparsers): def _add_list_subparser(sub): """`list` サブコマンドを登録する (project list / top-level list 共通)。 - NAME / PLUGIN / STATUS の一覧表示。`--interactive` で選択 → `project up` 起動。 + NAME / PLUGIN / STATUS の一覧表示。デフォルトで対話選択 → `project up` 起動。 + `--no-interactive` (`--plain`) で一覧表示のみ。非 TTY では自動的に一覧のみ。 """ p = sub.add_parser('list', help='List projects (NAME / PLUGIN / STATUS)') - p.add_argument('--interactive', '-i', action='store_true', - help='Select a project interactively and start it') + # 対話選択をデフォルト ON にする。`-i` / `--interactive` は後方互換のため + # 引き続き受け付ける (既に default=True なので実質 no-op)。 + p.add_argument('--interactive', '-i', dest='interactive', + action='store_true', default=True, + help='Select a project interactively and start it (default)') + p.add_argument('--no-interactive', '--plain', '-P', dest='interactive', + action='store_false', + help='Just print the table without interactive selection') def _add_env_parser(subparsers): diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index b0a4368..8ce9242 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -11,6 +11,7 @@ from __future__ import annotations import os +import sys from pathlib import Path from devbase.log import get_logger @@ -174,7 +175,11 @@ def cmd_project_list(devbase_root: Path, args) -> int: logger.info("プロジェクトがありません (%s)。", projects_dir) return 0 - if getattr(args, "interactive", False): + # 対話選択はデフォルト ON。ただし非 TTY (パイプ / CI / リダイレクト) では + # input() が EOFError になり実用にならないため、自動的に一覧表示へフォールバック。 + # stdin / stdout のいずれかが非 TTY (`devbase list | cat`, `> out.txt` 等) なら + # 対話プロンプトが表示できない / 読めないため、確実に一覧表示へフォールバックする。 + if getattr(args, "interactive", True) and sys.stdin.isatty() and sys.stdout.isatty(): return _interactive_select_and_up(rows) _print_table(rows) diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py index 0f52b14..b6c73a9 100644 --- a/tests/cli/test_completion.py +++ b/tests/cli/test_completion.py @@ -135,7 +135,7 @@ def test_bash_top_level_ps_flag_after_name(fake_root): def test_bash_project_list_flags(fake_root): out = _bash_complete("devbase project list '-'", 3, fake_root) - assert set(out) == {"--interactive", "-i"} + assert set(out) == {"--no-interactive", "--plain", "-P", "--interactive", "-i"} def test_bash_top_level_commands_include_project_and_list(fake_root): diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index f0e264c..881eb6f 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -222,6 +222,56 @@ def test_cmd_project_list_empty(tmp_path, capsys): assert rc == 0 +def test_cmd_project_list_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys): + """interactive=True (デフォルト) でも非 TTY では一覧表示にフォールバックする。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: False) + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + out = capsys.readouterr().out + + assert rc == 0 + assert called == [], "非 TTY では対話起動しない" + assert "alpha-proj" in out + + +def test_cmd_project_list_stdout_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys): + """stdin が TTY でも stdout が非 TTY (`devbase list | cat` / `> out.txt`) なら + 対話起動せず一覧表示へフォールバックする。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: False) + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + out = capsys.readouterr().out + + assert rc == 0 + assert called == [], "stdout 非 TTY では対話起動しない" + assert "alpha-proj" in out + + # --------------------------------------------------------------------------- # cmd_project_list: --interactive # --------------------------------------------------------------------------- @@ -237,6 +287,9 @@ def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch): _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + # 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。 + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) # 番号 "2" を選択 (sorted: alpha-proj=1, beta-proj=2) monkeypatch.setattr("builtins.input", lambda *a, **k: "2") @@ -261,6 +314,8 @@ def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch): _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda *a, **k: "") called = [] @@ -285,6 +340,8 @@ def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch): def raise_eof(*a, **k): raise EOFError + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) monkeypatch.setattr("builtins.input", raise_eof) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) @@ -308,6 +365,8 @@ def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkey def raise_interrupt(*a, **k): raise KeyboardInterrupt + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) monkeypatch.setattr("builtins.input", raise_interrupt) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) @@ -328,6 +387,8 @@ def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypat _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) # "99" (範囲外) → "1" (有効) の順に入力 → 再入力後に up が起動する inputs = iter(["99", "1"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) @@ -351,6 +412,8 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) # "abc" (数値以外) → "1" (有効) inputs = iter(["abc", "1"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) @@ -369,20 +432,30 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc # --------------------------------------------------------------------------- def test_parser_project_list(): + # 対話選択はデフォルト ON (フラグ無しで interactive=True)。 parser = cli._create_parser() args = parser.parse_args(["project", "list"]) assert args.command == "project" assert args.subcommand == "list" - assert args.interactive is False + assert args.interactive is True def test_parser_project_list_interactive_flag(): + # `-i` / `--interactive` は後方互換で受け付ける (実質 no-op、True のまま)。 parser = cli._create_parser() for flag in ("--interactive", "-i"): args = parser.parse_args(["project", "list", flag]) assert args.interactive is True +def test_parser_project_list_no_interactive_flag(): + # `--no-interactive` / `--plain` / `-P` で一覧表示のみ (interactive=False)。 + parser = cli._create_parser() + for flag in ("--no-interactive", "--plain", "-P"): + args = parser.parse_args(["project", "list", flag]) + assert args.interactive is False + + def test_parser_top_level_list_synonym(): parser = cli._create_parser() args = parser.parse_args(["list", "-i"])