Skip to content

Commit a3ac16b

Browse files
committed
fix: resolve {SCRIPT} placeholders for markdown agents in preset commands
1 parent aecef46 commit a3ac16b

4 files changed

Lines changed: 521 additions & 215 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to the Specify CLI and templates are documented here.
44

5+
## [0.3.39] - 2026-04-13
6+
7+
### Fixed
8+
9+
- **Preset commands for markdown agents**: Resolve `{SCRIPT}` placeholders correctly
10+
- Preset commands registered for markdown-based agents (opencode, claude, windsurf, etc.) now properly replace `{SCRIPT}` with actual script paths
11+
- Previously, `{SCRIPT}` was only resolved for skill-based agents (codex, kimi)
12+
- Root cause: `register_commands()` in `agents.py` didn't call `resolve_skill_placeholders()` for non-skill agents
13+
514
## [0.3.38] - 2026-04-13
615

716
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agentic-sdlc-specify-cli"
3-
version = "0.3.38"
3+
version = "0.3.39"
44
description = "Specify CLI (tikalk fork). Agentic SDLC toolkit for Spec-Driven Development with pre-installed extensions and AI integrations."
55
requires-python = ">=3.11"
66
dependencies = [

src/specify_cli/agents.py

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
def _build_agent_configs() -> dict[str, Any]:
1919
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
2020
from specify_cli.integrations import INTEGRATION_REGISTRY
21+
2122
configs: dict[str, dict[str, Any]] = {}
2223
for key, integration in INTEGRATION_REGISTRY.items():
2324
if key == "generic":
@@ -75,7 +76,7 @@ def parse_frontmatter(content: str) -> tuple[dict, str]:
7576
return {}, content
7677

7778
frontmatter_str = content[3:end_marker].strip()
78-
body = content[end_marker + 3:].strip()
79+
body = content[end_marker + 3 :].strip()
7980

8081
try:
8182
frontmatter = yaml.safe_load(frontmatter_str) or {}
@@ -100,7 +101,9 @@ def render_frontmatter(fm: dict) -> str:
100101
if not fm:
101102
return ""
102103

103-
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
104+
yaml_str = yaml.dump(
105+
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
106+
)
104107
return f"---\n{yaml_str}---\n"
105108

106109
def _adjust_script_paths(self, frontmatter: dict) -> dict:
@@ -146,16 +149,16 @@ def rewrite_project_relative_paths(text: str) -> str:
146149
# ".specify/extensions/<ext>/scripts/..." remain intact.
147150
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
148151
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
149-
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
152+
text = re.sub(
153+
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
154+
)
150155

151-
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
156+
return text.replace(".specify/.specify/", ".specify/").replace(
157+
".specify.specify/", ".specify/"
158+
)
152159

153160
def render_markdown_command(
154-
self,
155-
frontmatter: dict,
156-
body: str,
157-
source_id: str,
158-
context_note: str = None
161+
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
159162
) -> str:
160163
"""Render command in Markdown format.
161164
@@ -172,12 +175,7 @@ def render_markdown_command(
172175
context_note = f"\n<!-- Source: {source_id} -->\n"
173176
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
174177

175-
def render_toml_command(
176-
self,
177-
frontmatter: dict,
178-
body: str,
179-
source_id: str
180-
) -> str:
178+
def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str:
181179
"""Render command in TOML format.
182180
183181
Args:
@@ -192,7 +190,7 @@ def render_toml_command(
192190

193191
if "description" in frontmatter:
194192
toml_lines.append(
195-
f'description = {self._render_basic_toml_string(frontmatter["description"])}'
193+
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
196194
)
197195
toml_lines.append("")
198196

@@ -252,9 +250,13 @@ def render_skill_command(
252250
frontmatter = {}
253251

254252
if agent_name in {"codex", "kimi"}:
255-
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
253+
body = self.resolve_skill_placeholders(
254+
agent_name, frontmatter, body, project_root
255+
)
256256

257-
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
257+
description = frontmatter.get(
258+
"description", f"Spec-kit workflow command: {skill_name}"
259+
)
258260
skill_frontmatter = self.build_skill_frontmatter(
259261
agent_name,
260262
skill_name,
@@ -288,7 +290,9 @@ def build_skill_frontmatter(
288290
return skill_frontmatter
289291

290292
@staticmethod
291-
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
293+
def resolve_skill_placeholders(
294+
agent_name: str, frontmatter: dict, body: str, project_root: Path
295+
) -> str:
292296
"""Resolve script placeholders for skills-backed agents."""
293297
try:
294298
from . import load_init_options
@@ -312,7 +316,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr
312316
script_variant = init_opts.get("script")
313317
if script_variant not in {"sh", "ps"}:
314318
fallback_order = []
315-
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
319+
default_variant = (
320+
"ps" if platform.system().lower().startswith("win") else "sh"
321+
)
316322
secondary_variant = "sh" if default_variant == "ps" else "ps"
317323

318324
if default_variant in scripts or default_variant in agent_scripts:
@@ -334,15 +340,19 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr
334340
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
335341
body = body.replace("{SCRIPT}", script_command)
336342

337-
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
343+
agent_script_command = (
344+
agent_scripts.get(script_variant) if script_variant else None
345+
)
338346
if agent_script_command:
339347
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
340348
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
341349

342350
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
343351
return CommandRegistrar.rewrite_project_relative_paths(body)
344352

345-
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
353+
def _convert_argument_placeholder(
354+
self, content: str, from_placeholder: str, to_placeholder: str
355+
) -> str:
346356
"""Convert argument placeholder format.
347357
348358
Args:
@@ -356,14 +366,16 @@ def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_
356366
return content.replace(from_placeholder, to_placeholder)
357367

358368
@staticmethod
359-
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
369+
def _compute_output_name(
370+
agent_name: str, cmd_name: str, agent_config: Dict[str, Any]
371+
) -> str:
360372
"""Compute the on-disk command or skill name for an agent."""
361373
if agent_config["extension"] != "/SKILL.md":
362374
return cmd_name
363375

364376
short_name = cmd_name
365377
if short_name.startswith("speckit."):
366-
short_name = short_name[len("speckit."):]
378+
short_name = short_name[len("speckit.") :]
367379
short_name = short_name.replace(".", "-")
368380

369381
return f"speckit-{short_name}"
@@ -375,7 +387,7 @@ def register_commands(
375387
source_id: str,
376388
source_dir: Path,
377389
project_root: Path,
378-
context_note: str = None
390+
context_note: str = None,
379391
) -> List[str]:
380392
"""Register commands for a specific agent.
381393
@@ -422,6 +434,11 @@ def register_commands(
422434
if agent_config.get("inject_name") and not frontmatter.get("name"):
423435
frontmatter["name"] = cmd_name
424436

437+
# Resolve {SCRIPT} and {AGENT_SCRIPT} placeholders for all agents
438+
body = self.resolve_skill_placeholders(
439+
agent_name, frontmatter, body, project_root
440+
)
441+
425442
body = self._convert_argument_placeholder(
426443
body, "$ARGUMENTS", agent_config["args"]
427444
)
@@ -430,10 +447,18 @@ def register_commands(
430447

431448
if agent_config["extension"] == "/SKILL.md":
432449
output = self.render_skill_command(
433-
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
450+
agent_name,
451+
output_name,
452+
frontmatter,
453+
body,
454+
source_id,
455+
cmd_file,
456+
project_root,
434457
)
435458
elif agent_config["format"] == "markdown":
436-
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
459+
output = self.render_markdown_command(
460+
frontmatter, body, source_id, context_note
461+
)
437462
elif agent_config["format"] == "toml":
438463
output = self.render_toml_command(frontmatter, body, source_id)
439464
else:
@@ -449,7 +474,9 @@ def register_commands(
449474
registered.append(cmd_name)
450475

451476
for alias in cmd_info.get("aliases", []):
452-
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
477+
alias_output_name = self._compute_output_name(
478+
agent_name, alias, agent_config
479+
)
453480

454481
# For agents with inject_name, render with alias-specific frontmatter
455482
if agent_config.get("inject_name"):
@@ -458,23 +485,43 @@ def register_commands(
458485

459486
if agent_config["extension"] == "/SKILL.md":
460487
alias_output = self.render_skill_command(
461-
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root
488+
agent_name,
489+
alias_output_name,
490+
alias_frontmatter,
491+
body,
492+
source_id,
493+
cmd_file,
494+
project_root,
462495
)
463496
elif agent_config["format"] == "markdown":
464-
alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note)
497+
alias_output = self.render_markdown_command(
498+
alias_frontmatter, body, source_id, context_note
499+
)
465500
elif agent_config["format"] == "toml":
466-
alias_output = self.render_toml_command(alias_frontmatter, body, source_id)
501+
alias_output = self.render_toml_command(
502+
alias_frontmatter, body, source_id
503+
)
467504
else:
468-
raise ValueError(f"Unsupported format: {agent_config['format']}")
505+
raise ValueError(
506+
f"Unsupported format: {agent_config['format']}"
507+
)
469508
else:
470509
# For other agents, reuse the primary output
471510
alias_output = output
472511
if agent_config["extension"] == "/SKILL.md":
473512
alias_output = self.render_skill_command(
474-
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
513+
agent_name,
514+
alias_output_name,
515+
frontmatter,
516+
body,
517+
source_id,
518+
cmd_file,
519+
project_root,
475520
)
476521

477-
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
522+
alias_file = (
523+
commands_dir / f"{alias_output_name}{agent_config['extension']}"
524+
)
478525
alias_file.parent.mkdir(parents=True, exist_ok=True)
479526
alias_file.write_text(alias_output, encoding="utf-8")
480527
if agent_name == "copilot":
@@ -502,7 +549,7 @@ def register_commands_for_all_agents(
502549
source_id: str,
503550
source_dir: Path,
504551
project_root: Path,
505-
context_note: str = None
552+
context_note: str = None,
506553
) -> Dict[str, List[str]]:
507554
"""Register commands for all detected agents in the project.
508555
@@ -525,8 +572,12 @@ def register_commands_for_all_agents(
525572
if agent_dir.exists():
526573
try:
527574
registered = self.register_commands(
528-
agent_name, commands, source_id, source_dir, project_root,
529-
context_note=context_note
575+
agent_name,
576+
commands,
577+
source_id,
578+
source_dir,
579+
project_root,
580+
context_note=context_note,
530581
)
531582
if registered:
532583
results[agent_name] = registered
@@ -536,9 +587,7 @@ def register_commands_for_all_agents(
536587
return results
537588

538589
def unregister_commands(
539-
self,
540-
registered_commands: Dict[str, List[str]],
541-
project_root: Path
590+
self, registered_commands: Dict[str, List[str]], project_root: Path
542591
) -> None:
543592
"""Remove previously registered command files from agent directories.
544593
@@ -555,13 +604,17 @@ def unregister_commands(
555604
commands_dir = project_root / agent_config["dir"]
556605

557606
for cmd_name in cmd_names:
558-
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
607+
output_name = self._compute_output_name(
608+
agent_name, cmd_name, agent_config
609+
)
559610
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
560611
if cmd_file.exists():
561612
cmd_file.unlink()
562613

563614
if agent_name == "copilot":
564-
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
615+
prompt_file = (
616+
project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
617+
)
565618
if prompt_file.exists():
566619
prompt_file.unlink()
567620

0 commit comments

Comments
 (0)