Skip to content

Commit 68232db

Browse files
committed
Make custom commands tui only as well.
1 parent 6fada82 commit 68232db

5 files changed

Lines changed: 56 additions & 159 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ TUI slash commands: `/plan` `/build` `/init` `/review` `/explain`
129129
`/restore` `/clear` `/exit``/explain [project] [target]` explains
130130
code and `/summary` appends a conversation summary (both TUI-only);
131131
`/sessions` lists saved sessions and `/restore [path|title|--latest]`
132-
restores one (`/restore` matches sessions by title substring).
132+
restores one (`/restore` matches sessions by title substring). Custom
133+
commands from `prompts/commands/*.txt` are TUI slash commands too
134+
(TUI-only — no CLI subcommand is registered for them).
133135

134136
Input editing: type your message, press **Enter** for a new line, and
135137
**Esc then Enter** (or **Alt+Enter**) to submit. **Up/Down** recall

python_agent_harness/cli.py

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
Commands:
44
run [project] interactive TUI agent session (default)
55
config [--init] show effective LLM config / write a template file
6-
<custom> any prompt file in prompts/commands/
76
8-
init, review, sessions, restore (and summary/explain) are TUI slash
9-
commands only — see the TUI help for /init /review /sessions /restore.
7+
Custom commands (prompts/commands/*.txt) — like init, review,
8+
sessions, restore and summary/explain — are TUI slash commands only;
9+
they are NOT registered as CLI subcommands.
1010
1111
Configuration (LLM etc.) is read from a JSON file, by default
1212
~/.config/python-agent-harness/config.json; see `config --init`.
@@ -20,18 +20,10 @@
2020

2121
from . import config
2222
from .client import Client
23-
from .commands import (
24-
SessionCommand, find_command, load_custom_commands,
25-
)
2623
from .agent_session import AgentSession
2724
from .tools import default_registry
2825

2926

30-
# Commands available only as TUI slash commands (e.g. /explain) — they
31-
# are NOT registered as CLI subcommands.
32-
TUI_ONLY_COMMANDS = {"explain"}
33-
34-
3527
def make_session(
3628
project_dir: str,
3729
config_path: str | None = None,
@@ -122,40 +114,6 @@ def cmd_config(args: argparse.Namespace) -> int:
122114
return 0
123115

124116

125-
def cmd_custom(args: argparse.Namespace) -> int:
126-
cmd = find_command(args.command_name)
127-
if cmd is None:
128-
print(f"unknown custom command: {args.command_name}", file=sys.stderr)
129-
return 1
130-
_run_command(cmd, args.project, args.extra, args.config)
131-
return 0
132-
133-
134-
def _run_command(
135-
cmd: SessionCommand,
136-
project: str | None,
137-
extra: str | None,
138-
config_path: str | None = None,
139-
) -> None:
140-
project_dir = project or os.getcwd()
141-
session = make_session(project_dir, config_path=config_path)
142-
cmd.run(lambda **kw: _adopt(session, kw), project_dir=project_dir, extra=extra)
143-
144-
145-
def _adopt(session: AgentSession, kw: dict) -> AgentSession:
146-
# SessionCommand.run builds its own session kwargs; reuse ours.
147-
# The command's prompt becomes the "actual agent prompt"; the
148-
# project context + task-completion rules are kept in front of it.
149-
if kw.get("system_prompt") is not None:
150-
from .prompts import assemble_agent_prompt
151-
152-
session.system_prompt = assemble_agent_prompt(
153-
session.project_dir, kw["system_prompt"],
154-
context_path=getattr(session, "_configured_context_path", None),
155-
)
156-
return session
157-
158-
159117
def _add_config_arg(
160118
parser: argparse.ArgumentParser, suppress: bool = False
161119
) -> None:
@@ -186,15 +144,6 @@ def build_parser() -> argparse.ArgumentParser:
186144
p_config.add_argument("--force", action="store_true", help="overwrite an existing file")
187145
p_config.add_argument("--path", metavar="PATH", help="config file path")
188146
p_config.set_defaults(func=cmd_config)
189-
190-
for cmd in load_custom_commands():
191-
if cmd.name in TUI_ONLY_COMMANDS:
192-
continue # TUI slash command only (e.g. /explain)
193-
p = sub.add_parser(cmd.name, help=f"run custom command {cmd.name}")
194-
_add_config_arg(p, suppress=True)
195-
p.add_argument("project", nargs="?")
196-
p.add_argument("extra", nargs="?", help="arguments for the command")
197-
p.set_defaults(func=cmd_custom, command_name=cmd.name)
198147
return parser
199148

200149

@@ -205,8 +154,6 @@ def main(argv: list[str] | None = None) -> int:
205154
return cmd_run(args)
206155
if args.command == "config":
207156
return cmd_config(args)
208-
if hasattr(args, "func"):
209-
return args.func(args)
210157
parser.print_help()
211158
return 1
212159

python_agent_harness/commands.py

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
"""Session commands: init, review, summary, custom commands.
1+
"""Session commands: init, review, custom commands (TUI slash commands).
22
3-
Ported from gptel-agent-harness-commands.el. Each command builds a
4-
fresh session (buffer) with a prompt file as the system prompt and a
5-
kickoff message, then runs the agent loop.
3+
Ported from gptel-agent-harness-commands.el. Commands run inside the
4+
current TUI session (tui._run_slash_command): the command's prompt
5+
file becomes the run's system prompt, the project context and
6+
task-completion rules stay in front of it, and the kickoff message is
7+
the run's user text.
68
79
Tool availability per command:
810
- init/review: all tools EXCEPT PlanExit (they are one-shot runs that
@@ -18,9 +20,7 @@
1820
from pathlib import Path
1921
from typing import Any, Callable
2022

21-
from .agent import run_agent_loop
2223
from .prompts import read_prompt_file
23-
from .models import Message
2424

2525
PROMPTS_DIR = Path(__file__).parent / "prompts"
2626
COMMANDS_DIR = PROMPTS_DIR / "commands"
@@ -75,8 +75,8 @@ def prepare(
7575
) -> tuple[str, str, str]:
7676
"""Resolve (cwd, system_prompt, kickoff) without creating a session.
7777
78-
Shared by the CLI (which builds a fresh session) and the TUI
79-
slash commands (which run inside the current session).
78+
Used by the TUI slash commands, which run inside the current
79+
session.
8080
"""
8181
cwd = project_dir or _project_root(__import__("os").getcwd())
8282
prompt = _substitute(read_prompt_file(self.prompt_file), cwd, extra)
@@ -85,35 +85,6 @@ def prepare(
8585
kickoff = kickoff.replace("${path}", cwd)
8686
return cwd, prompt, kickoff
8787

88-
def run(
89-
self,
90-
session_factory,
91-
project_dir: str | None = None,
92-
extra: str | None = None,
93-
) -> None:
94-
"""Run the command: create a session and start the agent loop."""
95-
cwd, prompt, kickoff = self.prepare(project_dir, extra)
96-
session = session_factory(
97-
project_dir=cwd, system_prompt=prompt, kickoff=kickoff
98-
)
99-
# the command prompt is the "actual agent prompt"; the project
100-
# context and task-completion rules are kept in front of it
101-
from .prompts import assemble_agent_prompt
102-
103-
context_path = getattr(session, "_configured_context_path", None)
104-
system = assemble_agent_prompt(cwd, prompt, context_path=context_path)
105-
restore_planexit = hide_planexit(session) if not self.allow_planexit else None
106-
try:
107-
run_agent_loop(
108-
session,
109-
messages=[Message(role="user", content=kickoff)],
110-
top_level=True,
111-
system=system,
112-
)
113-
finally:
114-
if restore_planexit:
115-
restore_planexit()
116-
11788

11889
def initialize_command() -> SessionCommand:
11990
return SessionCommand(

tests/test_cli.py

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -91,20 +91,24 @@ def test_no_context_dir_no_prefix(self):
9191
finally:
9292
session.close()
9393

94-
def test_explain_not_a_cli_subcommand(self):
95-
"""explain is a TUI slash command only — no CLI subcommand.
94+
def test_custom_commands_not_cli_subcommands(self):
95+
"""Custom commands (e.g. explain) are TUI slash commands only —
96+
no CLI subcommand is registered for any of them.
9697
97-
It must still resolve as a SessionCommand so the TUI /explain
98+
They must still resolve as SessionCommands so the TUI /explain
9899
keeps working (via commands.find_command).
99100
"""
100-
from python_agent_harness.commands import find_command
101+
from python_agent_harness.commands import (
102+
find_command, load_custom_commands,
103+
)
101104

102105
parser = cli.build_parser()
103106
subparsers = next(
104107
a for a in parser._actions
105108
if a.__class__.__name__ == "_SubParsersAction"
106109
)
107-
self.assertNotIn("explain", subparsers.choices)
110+
for cmd in load_custom_commands():
111+
self.assertNotIn(cmd.name, subparsers.choices)
108112
self.assertIn("run", subparsers.choices)
109113
self.assertNotIn("review", subparsers.choices)
110114
self.assertNotIn("init", subparsers.choices)
@@ -176,40 +180,6 @@ def test_hide_planexit_removes_and_restores(self):
176180
finally:
177181
s.close()
178182

179-
def test_command_run_hides_planexit_for_init(self):
180-
"""SessionCommand.run hides PlanExit for the whole init run and
181-
restores it afterwards (even when the run raises)."""
182-
import unittest.mock as mock
183-
184-
from python_agent_harness.agent_session import AgentSession
185-
from python_agent_harness.commands import initialize_command
186-
from python_agent_harness.tools import default_registry
187-
188-
session = AgentSession(
189-
project_dir="/tmp", client=object(), model="m",
190-
registry=default_registry(),
191-
)
192-
session.switch_to_plan() # registers PlanExit
193-
try:
194-
def _loop(*a, **kw):
195-
# PlanExit stays hidden for the whole run (sub-agents
196-
# share this registry, so they are covered too)
197-
self.assertIsNone(session.registry.get("PlanExit"))
198-
raise RuntimeError("boom")
199-
200-
with mock.patch(
201-
"python_agent_harness.commands.run_agent_loop",
202-
side_effect=_loop,
203-
):
204-
with self.assertRaises(RuntimeError):
205-
initialize_command().run(
206-
lambda **kw: session, project_dir="/tmp"
207-
)
208-
# restored even though the run raised
209-
self.assertIsNotNone(session.registry.get("PlanExit"))
210-
finally:
211-
session.close()
212-
213183

214184
class TestCliSessionCommands(unittest.TestCase):
215185
def test_removed_cli_subcommands(self):

tests/test_context_rules.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -84,39 +84,46 @@ def test_make_session_injects_rules(self):
8484
finally:
8585
s.close()
8686

87-
def test_adopt_keeps_rules_before_command_prompt(self):
88-
"""init/review/custom commands: the command prompt is the agent
89-
prompt; rules stay in front of it."""
90-
from python_agent_harness.cli import _adopt
87+
def test_slash_commands_keep_rules_before_command_prompt(self):
88+
"""/init and custom commands (TUI slash path): the command
89+
prompt is the run's system prompt; rules stay in front of it."""
90+
import io
9191

92-
class Stub:
93-
project_dir = "/tmp"
92+
from rich.console import Console
9493

95-
s = Stub()
96-
_adopt(s, {"system_prompt": "COMMAND PROMPT"})
97-
self.assertIn("Task Completion Rules", s.system_prompt)
98-
self.assertLess(
99-
s.system_prompt.index("Task Completion Rules"),
100-
s.system_prompt.index("COMMAND PROMPT"),
94+
from python_agent_harness.agent_session import AgentSession
95+
from python_agent_harness.client import Client
96+
from python_agent_harness.tools import default_registry
97+
from python_agent_harness.tui import Tui
98+
99+
session = AgentSession(
100+
project_dir="/tmp", client=Client(
101+
base_url="http://127.0.0.1:1/v1", api_key="x", model="m",
102+
),
103+
model="m", registry=default_registry(),
101104
)
105+
tui = Tui(
106+
session, Console(file=io.StringIO(), width=100, force_terminal=False)
107+
)
108+
captured = {}
102109

103-
def test_command_run_loop_system_includes_rules(self):
104-
from python_agent_harness.commands import initialize_command
110+
def fake_start(text, system=None, restore=None):
111+
captured["system"] = system
105112

106-
with mock.patch("python_agent_harness.commands.run_agent_loop") as m:
107-
with tempfile.TemporaryDirectory() as d:
108-
cmd = initialize_command()
109-
cmd.run(
110-
session_factory=lambda **kw: object(),
111-
project_dir=d,
112-
extra=None,
113-
)
114-
_, kwargs = m.call_args
115-
self.assertIn("Task Completion Rules", kwargs["system"])
113+
with mock.patch.object(tui, "_start_agent", side_effect=fake_start):
114+
tui._handle_slash("/init")
115+
self.assertIn("Task Completion Rules", captured["system"])
116+
self.assertLess(
117+
captured["system"].index("Task Completion Rules"),
118+
captured["system"].index("AGENTS.md"),
119+
)
120+
tui._handle_slash("/explain client.py")
121+
self.assertIn("Task Completion Rules", captured["system"])
116122
self.assertLess(
117-
kwargs["system"].index("Task Completion Rules"),
118-
kwargs["system"].index("AGENTS.md"),
123+
captured["system"].index("Task Completion Rules"),
124+
captured["system"].index("You are a senior engineer"),
119125
)
126+
session.close()
120127

121128
def test_agent_loop_falls_back_to_session_prompt(self):
122129
"""A bare run_agent_loop without a system prompt still uses the

0 commit comments

Comments
 (0)