Skip to content

Commit 03c7875

Browse files
jwesleyeclaude
andcommitted
feat: add multi-directory template support with priority loading
Extends template system to load from three locations with configurable priority: - ~/.prompts (highest priority - existing/legacy templates) - ./.claude/commands (medium - project-specific overrides) - ~/.claude/commands (lowest - global fallback templates) This enables: - Project teams to share templates via git by committing ./.claude/commands - Users to maintain personal global templates in ~/.claude/commands - Backward compatibility with existing ~/.prompts setups Also adds: - Template grouping by source in #templates display - Command aliases: #prompts and #commands (in addition to #templates) - Config option to enable/disable Claude slash commands - Comprehensive test coverage (88% for template_manager) Tests cleaned up: - Rewrote response_renderer tests for simplified v1.8.0 implementation (24→7 tests) - Fixed config test isolation to prevent system ~/.chatrc interference - Updated display_manager tests for new grouped template API All 125 affected tests passing with zero regressions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 8fa48df commit 03c7875

11 files changed

Lines changed: 470 additions & 391 deletions

src/basic_agent_chat_loop/chat_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class ChatConfig:
4545
"show_tokens": True,
4646
"show_metadata": True,
4747
"readline_enabled": True,
48+
"claude_commands_enabled": True,
4849
},
4950
"paths": {
5051
"log_location": "~/.chat_loop_logs",

src/basic_agent_chat_loop/chat_loop.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -848,13 +848,27 @@ async def _async_run(self):
848848
continue
849849

850850
elif command_result.command_type == CommandType.TEMPLATES:
851-
# List available prompt templates
852-
templates = (
853-
self.template_manager.list_templates_with_descriptions()
854-
)
855-
self.display_manager.display_templates(
856-
templates, self.prompts_dir
851+
# Check if Claude commands are enabled
852+
claude_commands_enabled = (
853+
self.config.get(
854+
"features.claude_commands_enabled",
855+
True,
856+
agent_name=self.agent_name,
857+
)
858+
if self.config
859+
else True
857860
)
861+
if not claude_commands_enabled:
862+
print(
863+
Colors.system(
864+
"Claude slash commands are disabled in config"
865+
)
866+
)
867+
continue
868+
869+
# List available prompt templates grouped by source
870+
templates_grouped = self.template_manager.list_templates_grouped()
871+
self.display_manager.display_templates(templates_grouped)
858872
continue
859873

860874
elif command_result.command_type == CommandType.SESSIONS:
@@ -1119,6 +1133,24 @@ async def _async_run(self):
11191133
continue
11201134

11211135
elif command_result.command_type == CommandType.TEMPLATE:
1136+
# Check if Claude commands are enabled
1137+
claude_commands_enabled = (
1138+
self.config.get(
1139+
"features.claude_commands_enabled",
1140+
True,
1141+
agent_name=self.agent_name,
1142+
)
1143+
if self.config
1144+
else True
1145+
)
1146+
if not claude_commands_enabled:
1147+
print(
1148+
Colors.system(
1149+
"Claude slash commands are disabled in config"
1150+
)
1151+
)
1152+
continue
1153+
11221154
# Template command: /template_name <optional input>
11231155
# Extract template name and input using the router's helper
11241156
template_name, input_text = (

src/basic_agent_chat_loop/components/command_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def _parse_hash_command(self, stripped: str, original_input: str) -> CommandResu
161161
original_input=original_input,
162162
is_command=True,
163163
)
164-
elif base_cmd == "templates":
164+
elif base_cmd in ("templates", "prompts", "commands"):
165165
return CommandResult(
166166
command_type=CommandType.TEMPLATES,
167167
original_input=original_input,

src/basic_agent_chat_loop/components/config_wizard.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def reset_config_to_defaults() -> Optional[Path]:
7171
"show_metadata": True,
7272
"rich_enabled": True,
7373
"readline_enabled": True,
74+
"claude_commands_enabled": True,
7475
},
7576
"ui": {
7677
"show_banner": True,
@@ -430,6 +431,18 @@ def _configure_features(self):
430431
help_text="Allows using arrow keys to navigate command history",
431432
)
432433

434+
# claude_commands_enabled
435+
current_claude_commands = (
436+
self.current_config.get("features.claude_commands_enabled", True)
437+
if self.current_config
438+
else True
439+
)
440+
self.config["features"]["claude_commands_enabled"] = self._prompt_bool(
441+
"Enable Claude slash commands (/template_name)?",
442+
default=current_claude_commands,
443+
help_text="Allows using prompt templates from ~/.prompts, ./.claude/commands, ~/.claude/commands",
444+
)
445+
433446
def _configure_ui(self):
434447
"""Configure UI section."""
435448
print("\n" + "=" * 70)

src/basic_agent_chat_loop/components/display_manager.py

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def display_banner(self):
9999
print(" #help - Show this help message")
100100
print(" #info - Show detailed agent information")
101101
print(" #context - Show token usage and context statistics")
102-
print(" #templates - List available prompt templates")
102+
print(" #templates - List available prompt templates (#prompts, #commands)")
103103
print(" #sessions - List saved conversation sessions")
104104
print(" /name - Use prompt template from ~/.prompts/name.md")
105105
print(" #resume <#> - Resume a previous session by number or ID")
@@ -139,7 +139,7 @@ def display_help(self):
139139
print(" #help - Show this help message")
140140
print(" #info - Show detailed agent information")
141141
print(" #context - Show token usage and context statistics")
142-
print(" #templates - List available prompt templates")
142+
print(" #templates - List available prompt templates (#prompts, #commands)")
143143
print(" #sessions - List saved conversation sessions")
144144
print(" /name - Use prompt template from ~/.prompts/name.md")
145145
print(" #resume <#> - Resume a previous session by number or ID")
@@ -279,32 +279,54 @@ def display_session_summary(
279279

280280
print(f"{Colors.DIM}{'=' * 60}{Colors.RESET}")
281281

282-
def display_templates(self, templates: list, prompts_dir: Path):
282+
def display_templates(self, templates_grouped: list):
283283
"""
284-
Display available templates.
284+
Display available templates grouped by source.
285285
286286
Args:
287-
templates: List of (name, description) tuples or list of names
288-
prompts_dir: Path to prompts directory
287+
templates_grouped: List of (directory_path, templates) tuples where
288+
templates is a list of (name, description) tuples
289289
"""
290-
if templates:
291-
print(
292-
f"\n{Colors.system('Available Prompt Templates')} ({len(templates)}):"
293-
)
290+
if not templates_grouped:
291+
print(f"\n{Colors.system('No prompt templates found')}")
292+
print("Create templates in one of these locations:")
293+
print(" ~/.prompts/")
294+
print(" ./.claude/commands/")
295+
print(" ~/.claude/commands/")
296+
print(f"Example: ~/.prompts/review.md")
297+
return
298+
299+
# Count total templates across all sources
300+
total_count = sum(len(templates) for _, templates in templates_grouped)
301+
302+
print(f"\n{Colors.system('Available Prompt Templates')} ({total_count}):")
303+
print(f"{Colors.DIM}{'=' * 60}{Colors.RESET}")
304+
305+
# Track which templates we've seen to detect overrides
306+
seen_templates = set()
307+
308+
# Display in reverse order so lowest priority shows first
309+
# This makes overrides appear later and be more obvious
310+
for directory, templates in reversed(templates_grouped):
311+
print(f"\n{Colors.system(f'Templates from {directory}:')}")
294312
print(f"{Colors.DIM}{'-' * 60}{Colors.RESET}")
295-
for item in templates:
296-
if isinstance(item, tuple):
297-
name, desc = item
298-
print(f" {Colors.success('/' + name)} - {desc}")
313+
314+
for name, desc in templates:
315+
override_indicator = ""
316+
if name in seen_templates:
317+
override_indicator = f" {Colors.DIM}(overrides previous){Colors.RESET}"
299318
else:
300-
print(f" {Colors.success('/' + item)}")
301-
print(f"{Colors.DIM}{'-' * 60}{Colors.RESET}")
302-
print(Colors.system("Usage: /template_name <optional context>"))
303-
print(Colors.system(f"Location: {prompts_dir}"))
304-
else:
305-
print(f"\n{Colors.system('No prompt templates found')}")
306-
print(f"Create templates in: {prompts_dir}")
307-
print(f"Example: {prompts_dir}/review.md")
319+
seen_templates.add(name)
320+
321+
print(f" {Colors.success('/' + name)} - {desc}{override_indicator}")
322+
323+
print(f"\n{Colors.DIM}{'=' * 60}{Colors.RESET}")
324+
print(Colors.system("Usage: /template_name <optional context>"))
325+
print(
326+
Colors.system(
327+
"Priority: ~/.prompts > ./.claude/commands > ~/.claude/commands"
328+
)
329+
)
308330

309331
def display_sessions(self, sessions: list, agent_name: Optional[str] = None):
310332
"""

src/basic_agent_chat_loop/components/template_manager.py

Lines changed: 98 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,28 @@
1212

1313

1414
class TemplateManager:
15-
"""Manage prompt templates from ~/.prompts/ directory."""
15+
"""Manage prompt templates from multiple directories with priority."""
1616

1717
def __init__(self, prompts_dir: Optional[Path] = None):
1818
"""
1919
Initialize template manager.
2020
2121
Args:
22-
prompts_dir: Directory containing templates (defaults to ~/.prompts/)
22+
prompts_dir: Base prompts directory (defaults to ~/.prompts/)
23+
This is used for backward compatibility and initialization.
2324
"""
24-
self.prompts_dir = prompts_dir or Path.home() / ".prompts"
25+
base_prompts_dir = prompts_dir or Path.home() / ".prompts"
26+
27+
# Template directories in priority order (first = highest priority)
28+
# Priority: ~/.prompts > ./.claude/commands > ~/.claude/commands
29+
self.template_dirs = [
30+
base_prompts_dir, # Legacy/base (highest priority)
31+
Path.cwd() / ".claude" / "commands", # Project-specific (medium)
32+
Path.home() / ".claude" / "commands", # User global (lowest priority)
33+
]
34+
35+
# Keep prompts_dir for backward compatibility (used for initialization)
36+
self.prompts_dir = base_prompts_dir
2537
self._initialize_templates()
2638

2739
def _initialize_templates(self):
@@ -173,7 +185,10 @@ def _create_sample_template(self, name: str, content: str):
173185

174186
def load_template(self, template_name: str, input_text: str = "") -> Optional[str]:
175187
"""
176-
Load a prompt template from ~/.prompts/{template_name}.md
188+
Load a prompt template, checking directories in priority order.
189+
190+
Checks in order: ~/.prompts, ./.claude/commands, ~/.claude/commands
191+
Returns the first match found (highest priority wins).
177192
178193
Args:
179194
template_name: Name of the template (without .md extension)
@@ -182,70 +197,85 @@ def load_template(self, template_name: str, input_text: str = "") -> Optional[st
182197
Returns:
183198
Processed template text, or None if template not found
184199
"""
185-
template_path = self.prompts_dir / f"{template_name}.md"
200+
# Check directories in priority order (highest priority first)
201+
for template_dir in self.template_dirs:
202+
template_path = template_dir / f"{template_name}.md"
186203

187-
if not template_path.exists():
188-
return None
204+
if not template_path.exists():
205+
continue
189206

190-
try:
191-
with open(template_path, encoding="utf-8") as f:
192-
template = f.read()
207+
try:
208+
with open(template_path, encoding="utf-8") as f:
209+
template = f.read()
193210

194-
# Replace {input} placeholder with provided text
195-
if "{input}" in template:
196-
template = template.replace("{input}", input_text)
197-
elif input_text:
198-
# If no {input} placeholder but input provided, append it
199-
template = f"{template}\n\n{input_text}"
211+
# Replace {input} placeholder with provided text
212+
if "{input}" in template:
213+
template = template.replace("{input}", input_text)
214+
elif input_text:
215+
# If no {input} placeholder but input provided, append it
216+
template = f"{template}\n\n{input_text}"
200217

201-
return template
218+
return template
202219

203-
except Exception as e:
204-
logger.error(f"Error loading template {template_name}: {e}")
205-
return None
220+
except Exception as e:
221+
logger.error(f"Error loading template {template_name}: {e}")
222+
continue
223+
224+
return None
206225

207226
def list_templates(self) -> list[str]:
208227
"""
209-
List available prompt templates from ~/.prompts/
228+
List all available templates from all directories (deduplicated).
210229
211230
Returns:
212-
List of template names (without .md extension)
231+
Sorted list of unique template names (without .md extension)
213232
"""
214-
if not self.prompts_dir.exists():
215-
return []
233+
templates = set()
216234

217-
templates = []
218-
for file in self.prompts_dir.glob("*.md"):
219-
templates.append(file.stem)
235+
for template_dir in self.template_dirs:
236+
if not template_dir.exists():
237+
continue
238+
239+
for file in template_dir.glob("*.md"):
240+
templates.add(file.stem)
220241

221242
return sorted(templates)
222243

223-
def get_template_info(self, template_name: str) -> Optional[tuple[str, str]]:
244+
def get_template_info(
245+
self, template_name: str, template_dir: Optional[Path] = None
246+
) -> Optional[tuple[str, str]]:
224247
"""
225248
Get template description from first line.
226249
227250
Args:
228251
template_name: Name of the template
252+
template_dir: Specific directory to check (if None, uses priority order)
229253
230254
Returns:
231255
Tuple of (name, description) or None if not found
232256
"""
233-
template_path = self.prompts_dir / f"{template_name}.md"
257+
# If specific directory provided, check only that one
258+
dirs_to_check = [template_dir] if template_dir else self.template_dirs
234259

235-
if not template_path.exists():
236-
return None
260+
for dir_path in dirs_to_check:
261+
template_path = dir_path / f"{template_name}.md"
237262

238-
try:
239-
with open(template_path, encoding="utf-8") as f:
240-
first_line = f.readline().strip()
241-
# Extract description from markdown heading
242-
if first_line.startswith("# "):
243-
description = first_line[2:].strip()
244-
else:
245-
description = template_name
246-
return (template_name, description)
247-
except Exception:
248-
return (template_name, template_name)
263+
if not template_path.exists():
264+
continue
265+
266+
try:
267+
with open(template_path, encoding="utf-8") as f:
268+
first_line = f.readline().strip()
269+
# Extract description from markdown heading
270+
if first_line.startswith("# "):
271+
description = first_line[2:].strip()
272+
else:
273+
description = template_name
274+
return (template_name, description)
275+
except Exception:
276+
return (template_name, template_name)
277+
278+
return None
249279

250280
def list_templates_with_descriptions(self) -> list[tuple[str, str]]:
251281
"""
@@ -260,3 +290,29 @@ def list_templates_with_descriptions(self) -> list[tuple[str, str]]:
260290
if info:
261291
templates.append(info)
262292
return templates
293+
294+
def list_templates_grouped(self) -> list[tuple[Path, list[tuple[str, str]]]]:
295+
"""
296+
List templates grouped by source directory.
297+
298+
Returns:
299+
List of (directory_path, templates) tuples where templates is a list
300+
of (name, description) tuples. Directories are returned in priority order.
301+
"""
302+
grouped = []
303+
304+
for template_dir in self.template_dirs:
305+
if not template_dir.exists():
306+
continue
307+
308+
templates_in_dir = []
309+
for file in template_dir.glob("*.md"):
310+
template_name = file.stem
311+
info = self.get_template_info(template_name, template_dir)
312+
if info:
313+
templates_in_dir.append(info)
314+
315+
if templates_in_dir:
316+
grouped.append((template_dir, sorted(templates_in_dir)))
317+
318+
return grouped

0 commit comments

Comments
 (0)