From 574125d7dd468f318e9f4e330e9005e3209b33e1 Mon Sep 17 00:00:00 2001 From: BukeLy Date: Tue, 26 May 2026 14:14:45 +0800 Subject: [PATCH] fix(filesystem): add pifs ask and chat commands --- pageindex/filesystem/cli.py | 144 +++++++++++++++++++++++++++++++++--- pyproject.toml | 3 + tests/test_pifs_cli.py | 113 ++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 tests/test_pifs_cli.py diff --git a/pageindex/filesystem/cli.py b/pageindex/filesystem/cli.py index 0cdf632..04ff8c9 100644 --- a/pageindex/filesystem/cli.py +++ b/pageindex/filesystem/cli.py @@ -6,10 +6,128 @@ import shlex import sys from pathlib import Path +from .agent import REASONING_EFFORT_CHOICES, REASONING_SUMMARY_CHOICES, run_pifs_agent from .commands import PIFSCommandError, PIFSCommandExecutor from .core import PageIndexFileSystem +AGENT_STREAM_MODE_CHOICES = ("off", "tools", "model", "all") +DEFAULT_AGENT_MODEL = "gpt-5.4-mini" +EXIT_COMMANDS = {"exit", "quit", ":q"} + + +def _agent_model_default() -> str: + return ( + os.environ.get("PIFS_AGENT_MODEL") + or os.environ.get("PIFS_MODEL") + or DEFAULT_AGENT_MODEL + ) + + +def _add_agent_arguments( + parser: argparse.ArgumentParser, + *, + workspace_default: str | None, +) -> None: + parser.add_argument("--workspace", default=workspace_default) + parser.add_argument("--model", default=_agent_model_default()) + parser.add_argument( + "--stream-mode", + default="off", + choices=AGENT_STREAM_MODE_CHOICES, + ) + parser.add_argument("--max-turns", type=int, default=20) + parser.add_argument("--max-seconds", type=float, default=60) + parser.add_argument( + "--reasoning-effort", + choices=REASONING_EFFORT_CHOICES, + default=None, + ) + parser.add_argument( + "--reasoning-summary", + choices=REASONING_SUMMARY_CHOICES, + default=None, + ) + + +def _parse_agent_command( + command_name: str, + argv: list[str], + *, + workspace_default: str | None, +) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog=f"pifs {command_name}", + description=f"PageIndex FileSystem {command_name}", + ) + _add_agent_arguments(parser, workspace_default=workspace_default) + if command_name == "ask": + parser.add_argument("question", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + if not args.workspace: + parser.error("--workspace is required unless PIFS_WORKSPACE is set") + return args + + +def _filesystem_from_workspace(workspace: str) -> PageIndexFileSystem: + return PageIndexFileSystem(Path(workspace).expanduser()) + + +def _agent_kwargs(args: argparse.Namespace) -> dict[str, object]: + return { + "model": args.model, + "stream_mode": args.stream_mode, + "max_turns": args.max_turns, + "max_seconds": args.max_seconds, + "reasoning_effort": args.reasoning_effort, + "reasoning_summary": args.reasoning_summary, + } + + +def _run_ask(argv: list[str], *, workspace_default: str | None) -> int: + args = _parse_agent_command("ask", argv, workspace_default=workspace_default) + question_tokens = [token for token in args.question if token != "--"] + question = " ".join(question_tokens).strip() + if not question: + raise ValueError("ask requires a question") + + filesystem = _filesystem_from_workspace(args.workspace) + print(run_pifs_agent(filesystem, question, **_agent_kwargs(args))) + return 0 + + +def _run_chat(argv: list[str], *, workspace_default: str | None) -> int: + args = _parse_agent_command("chat", argv, workspace_default=workspace_default) + filesystem = _filesystem_from_workspace(args.workspace) + while True: + try: + question = input("pifs> ").strip() + except EOFError: + break + except KeyboardInterrupt: + print() + break + if not question: + continue + if question.lower() in EXIT_COMMANDS: + break + print(run_pifs_agent(filesystem, question, **_agent_kwargs(args))) + return 0 + + +def _run_passthrough( + command_tokens: list[str], + *, + workspace: str, + json_output: bool, +) -> int: + filesystem = _filesystem_from_workspace(workspace) + executor = PIFSCommandExecutor(filesystem, json_output=json_output) + command = " ".join(shlex.quote(token) for token in command_tokens) + print(executor.execute(command)) + return 0 + + def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) parser = argparse.ArgumentParser(description="PageIndex FileSystem CLI") @@ -20,20 +138,28 @@ def main(argv: list[str] | None = None) -> int: command_tokens = [token for token in args.command if token != "--"] json_output = args.json_output - if "--json" in command_tokens: - command_tokens = [token for token in command_tokens if token != "--json"] - json_output = True - if not args.workspace: - parser.error("--workspace is required unless PIFS_WORKSPACE is set") if not command_tokens: parser.error("a filesystem command is required") - filesystem = PageIndexFileSystem(Path(args.workspace).expanduser()) - executor = PIFSCommandExecutor(filesystem, json_output=json_output) try: - command = " ".join(shlex.quote(token) for token in command_tokens) - print(executor.execute(command)) + command_name = command_tokens[0] + command_args = command_tokens[1:] + if command_name == "ask": + return _run_ask(command_args, workspace_default=args.workspace) + if command_name == "chat": + return _run_chat(command_args, workspace_default=args.workspace) + + if "--json" in command_tokens: + command_tokens = [token for token in command_tokens if token != "--json"] + json_output = True + if not args.workspace: + parser.error("--workspace is required unless PIFS_WORKSPACE is set") + return _run_passthrough( + command_tokens, + workspace=args.workspace, + json_output=json_output, + ) except PIFSCommandError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 diff --git a/pyproject.toml b/pyproject.toml index ee37480..7458247 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,3 +12,6 @@ dependencies = [ "pyyaml==6.0.2", "sqlite-vec>=0.1.9", ] + +[project.scripts] +pifs = "pageindex.filesystem.cli:main" diff --git a/tests/test_pifs_cli.py b/tests/test_pifs_cli.py new file mode 100644 index 0000000..d08c053 --- /dev/null +++ b/tests/test_pifs_cli.py @@ -0,0 +1,113 @@ +from pathlib import Path + + +class FakeFileSystem: + def __init__(self, workspace): + self.workspace = Path(workspace) + + +def test_cli_passthrough_invokes_pifs_command_executor(monkeypatch, capsys, tmp_path): + from pageindex.filesystem import cli + + workspace = tmp_path / "workspace" + executor_instances = [] + + class FakeExecutor: + def __init__(self, filesystem, *, json_output=False): + self.filesystem = filesystem + self.json_output = json_output + self.commands = [] + executor_instances.append(self) + + def execute(self, command): + self.commands.append(command) + return f"executed:{command}" + + monkeypatch.setattr(cli, "PageIndexFileSystem", FakeFileSystem) + monkeypatch.setattr(cli, "PIFSCommandExecutor", FakeExecutor) + + status = cli.main(["--workspace", str(workspace), "ls", "/documents", "--json"]) + + assert status == 0 + assert capsys.readouterr().out == "executed:ls /documents\n" + assert len(executor_instances) == 1 + assert executor_instances[0].filesystem.workspace == workspace + assert executor_instances[0].json_output is True + assert executor_instances[0].commands == ["ls /documents"] + + +def test_cli_ask_invokes_agent_with_question(monkeypatch, capsys, tmp_path): + from pageindex.filesystem import cli + + workspace = tmp_path / "workspace" + agent_calls = [] + + def fake_run_pifs_agent(filesystem, question, **kwargs): + agent_calls.append((filesystem, question, kwargs)) + return "agent answer" + + monkeypatch.setattr(cli, "PageIndexFileSystem", FakeFileSystem) + monkeypatch.setattr(cli, "run_pifs_agent", fake_run_pifs_agent) + + status = cli.main( + [ + "ask", + "--workspace", + str(workspace), + "--model", + "test-model", + "--stream-mode", + "tools", + "--max-turns", + "7", + "--max-seconds", + "3.5", + "--reasoning-effort", + "low", + "--reasoning-summary", + "concise", + "What", + "is", + "inside?", + ] + ) + + assert status == 0 + assert capsys.readouterr().out == "agent answer\n" + filesystem, question, kwargs = agent_calls[0] + assert filesystem.workspace == workspace + assert question == "What is inside?" + assert kwargs == { + "model": "test-model", + "stream_mode": "tools", + "max_turns": 7, + "max_seconds": 3.5, + "reasoning_effort": "low", + "reasoning_summary": "concise", + } + + +def test_cli_chat_runs_one_question_and_exits(monkeypatch, capsys, tmp_path): + from pageindex.filesystem import cli + + workspace = tmp_path / "workspace" + inputs = iter(["", "Summarize the workspace", "exit"]) + agent_calls = [] + + def fake_run_pifs_agent(filesystem, question, **kwargs): + agent_calls.append((filesystem, question, kwargs)) + return f"answer:{question}" + + monkeypatch.setattr(cli, "PageIndexFileSystem", FakeFileSystem) + monkeypatch.setattr(cli, "run_pifs_agent", fake_run_pifs_agent) + monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs)) + + status = cli.main(["chat", "--workspace", str(workspace), "--model", "test-model"]) + + assert status == 0 + assert capsys.readouterr().out == "answer:Summarize the workspace\n" + assert len(agent_calls) == 1 + filesystem, question, kwargs = agent_calls[0] + assert filesystem.workspace == workspace + assert question == "Summarize the workspace" + assert kwargs["model"] == "test-model"