Skip to content
This repository was archived by the owner on Sep 30, 2025. It is now read-only.

Commit 22eba39

Browse files
authored
Merge pull request #24 from dreadnode/simone/eng-458-cli-move-to-manifests-for-agent-templates
eng-458: implemented agent templates system
2 parents 28fcec0 + 17a8453 commit 22eba39

File tree

26 files changed

+473
-411
lines changed

26 files changed

+473
-411
lines changed

CLI.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ $ dreadnode agent [OPTIONS] COMMAND [ARGS]...
5050
* `show`: Show the status of the active agent
5151
* `strikes`: List available strikes
5252
* `switch`: Switch to a different agent link
53-
* `templates`: List available agent templates with their...
53+
* `templates`: Interact with Strike templates
5454
* `versions`: List historical versions of the active agent
5555

5656
### `dreadnode agent clone`
@@ -108,7 +108,7 @@ $ dreadnode agent init [OPTIONS] STRIKE
108108

109109
* `-d, --dir DIRECTORY`: The directory to initialize [default: .]
110110
* `-n, --name TEXT`: The project name (used for container naming)
111-
* `-t, --template [rigging_basic|rigging_loop|nerve_basic]`: The template to use for the agent [default: rigging_basic]
111+
* `-t, --template TEXT`: The template to use for the agent
112112
* `-s, --source TEXT`: Initialize the agent using a custom template from a github repository, ZIP archive URL or local folder
113113
* `-p, --path TEXT`: If --source has been provided, use --path to specify a subfolder to initialize from
114114
* `--help`: Show this message and exit.
@@ -256,12 +256,49 @@ $ dreadnode agent switch [OPTIONS] AGENT_OR_PROFILE [DIRECTORY]
256256

257257
### `dreadnode agent templates`
258258

259+
Interact with Strike templates
260+
261+
**Usage**:
262+
263+
```console
264+
$ dreadnode agent templates [OPTIONS] COMMAND [ARGS]...
265+
```
266+
267+
**Options**:
268+
269+
* `--help`: Show this message and exit.
270+
271+
**Commands**:
272+
273+
* `install`: Install a template pack
274+
* `show`: List available agent templates with their...
275+
276+
#### `dreadnode agent templates install`
277+
278+
Install a template pack
279+
280+
**Usage**:
281+
282+
```console
283+
$ dreadnode agent templates install [OPTIONS] [SOURCE]
284+
```
285+
286+
**Arguments**:
287+
288+
* `[SOURCE]`: The source of the template pack [default: dreadnode/basic-agents]
289+
290+
**Options**:
291+
292+
* `--help`: Show this message and exit.
293+
294+
#### `dreadnode agent templates show`
295+
259296
List available agent templates with their descriptions
260297

261298
**Usage**:
262299

263300
```console
264-
$ dreadnode agent templates [OPTIONS]
301+
$ dreadnode agent templates show [OPTIONS]
265302
```
266303

267304
**Options**:

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,10 @@ Interact with Strike agents:
139139
dreadnode agent strikes
140140

141141
# list all available templates with their descriptions
142-
dreadnode agent templates
142+
dreadnode agent templates show
143+
144+
# install a template pack from a github repository
145+
dreadnode agent templates install dreadnode/basic-templates
143146

144147
# initialize a new agent in the current directory
145148
dreadnode agent init -t <template_name> <strike_id>

dreadnode_cli/agent/cli.py

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@
2020
format_run,
2121
format_runs,
2222
format_strikes,
23-
format_templates,
2423
)
25-
from dreadnode_cli.agent.templates import Template, install_template, install_template_from_dir
24+
from dreadnode_cli.agent.templates import cli as templates_cli
25+
from dreadnode_cli.agent.templates.format import format_templates
26+
from dreadnode_cli.agent.templates.manager import TemplateManager
2627
from dreadnode_cli.config import UserConfig
2728
from dreadnode_cli.profile.cli import switch as switch_profile
2829
from dreadnode_cli.types import GithubRepo
29-
from dreadnode_cli.utils import download_and_unzip_archive, pretty_cli, repo_exists
30+
from dreadnode_cli.utils import download_and_unzip_archive, get_repo_archive_source_path, pretty_cli
3031

3132
cli = typer.Typer(no_args_is_help=True)
3233

34+
cli.add_typer(templates_cli, name="templates", help="Interact with Strike templates")
35+
3336

3437
def ensure_profile(agent_config: AgentConfig, *, user_config: UserConfig | None = None) -> None:
3538
"""Ensure the active agent link matches the current server profile."""
@@ -66,26 +69,6 @@ def ensure_profile(agent_config: AgentConfig, *, user_config: UserConfig | None
6669
switch_profile(agent_config.active_link.profile)
6770

6871

69-
def get_repo_archive_source_path(source_dir: pathlib.Path) -> pathlib.Path:
70-
"""Return the actual source directory from a git repositoryZIP archive."""
71-
72-
if not (source_dir / "Dockerfile").exists() and not (source_dir / "Dockerfile.j2").exists():
73-
# if src has been downloaded from a ZIP archive, it may contain a single
74-
# '<user>-<repo>-<commit hash>' folder, that is the actual source we want to use.
75-
# Check if source_dir contains only one folder and update it if so.
76-
children = list(source_dir.iterdir())
77-
if len(children) == 1 and children[0].is_dir():
78-
source_dir = children[0]
79-
80-
return source_dir
81-
82-
83-
@cli.command(help="List available agent templates with their descriptions")
84-
@pretty_cli
85-
def templates() -> None:
86-
print(format_templates())
87-
88-
8972
@cli.command(help="Initialize a new agent project")
9073
@pretty_cli
9174
def init(
@@ -98,8 +81,8 @@ def init(
9881
str | None, typer.Option("--name", "-n", help="The project name (used for container naming)")
9982
] = None,
10083
template: t.Annotated[
101-
Template, typer.Option("--template", "-t", help="The template to use for the agent")
102-
] = Template.rigging_basic,
84+
str | None, typer.Option("--template", "-t", help="The template to use for the agent")
85+
] = None,
10386
source: t.Annotated[
10487
str | None,
10588
typer.Option(
@@ -137,18 +120,45 @@ def init(
137120
print(f":crossed_swords: Linking to strike '{strike_response.name}' ({strike_response.type})")
138121
print()
139122

140-
project_name = Prompt.ask("Project name?", default=name or directory.name)
123+
project_name = Prompt.ask(":toolbox: Project name?", default=name or directory.name)
141124
print()
142125

143126
directory.mkdir(exist_ok=True)
144127

128+
template_manager = TemplateManager()
145129
context = {"project_name": project_name, "strike": strike_response}
146130

147131
if source is None:
148-
# initialize from builtin template
149-
template = Template(Prompt.ask("Template?", choices=[t.value for t in Template], default=template.value))
132+
# get the templates that match the strike
133+
available_templates = template_manager.get_templates_for_strike(strike_response.name, strike_response.type)
134+
available: list[str] = list(available_templates.keys())
135+
136+
# none available
137+
if not available:
138+
if not template_manager.templates:
139+
raise Exception(
140+
"No templates installed, use [bold]dreadnode agent templates install[/] to install some."
141+
)
142+
else:
143+
raise Exception("No templates found for the given strike.")
144+
145+
# ask the user if the template has not been passed via command line
146+
if template is None:
147+
print(":notebook: Compatible templates:\n")
148+
print(format_templates(available_templates, with_index=True))
149+
print()
150+
151+
choice = Prompt.ask("Choice ", choices=[str(i + 1) for i in range(len(available))])
152+
template = available[int(choice) - 1]
153+
154+
# validate the template
155+
if template not in available:
156+
raise Exception(
157+
f"Template '{template}' not found, use [bold]dreadnode agent templates show[/] to see available templates."
158+
)
150159

151-
install_template(template, directory, context)
160+
# install the template
161+
template_manager.install(template, directory, context)
152162
else:
153163
source_dir = pathlib.Path(source)
154164
cleanup = False
@@ -162,7 +172,7 @@ def init(
162172
github_repo = GithubRepo(source)
163173

164174
# Check if the repo is accessible
165-
if repo_exists(github_repo):
175+
if github_repo.exists:
166176
source_dir = download_and_unzip_archive(github_repo.zip_url)
167177

168178
# This could be a private repo that the user can access
@@ -193,7 +203,8 @@ def init(
193203
if path is not None:
194204
source_dir = source_dir / path
195205

196-
install_template_from_dir(source_dir, directory, context)
206+
# install the template
207+
template_manager.install_from_dir(source_dir, directory, context)
197208
except Exception:
198209
if cleanup and source_dir.exists():
199210
shutil.rmtree(source_dir)
@@ -521,7 +532,7 @@ def clone(
521532
shutil.rmtree(target)
522533

523534
# Check if the repo is accessible
524-
if repo_exists(github_repo):
535+
if github_repo.exists:
525536
temp_dir = download_and_unzip_archive(github_repo.zip_url)
526537

527538
# This could be a private repo that the user can access

dreadnode_cli/agent/format.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from rich.text import Text
1010

1111
from dreadnode_cli import api
12-
from dreadnode_cli.agent.templates import Template, template_description
1312

1413
P = t.ParamSpec("P")
1514

@@ -312,14 +311,3 @@ def format_runs(runs: list[api.Client.StrikeRunSummaryResponse]) -> RenderableTy
312311
)
313312

314313
return table
315-
316-
317-
def format_templates() -> RenderableType:
318-
table = Table(box=box.ROUNDED)
319-
table.add_column("template")
320-
table.add_column("description")
321-
322-
for template in Template:
323-
table.add_row(f"[bold magenta]{template.value}[/]", template_description(template))
324-
325-
return table
Lines changed: 2 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,3 @@
1-
import enum
2-
import pathlib
3-
import typing as t
1+
from dreadnode_cli.agent.templates.cli import cli
42

5-
from jinja2 import Environment, FileSystemLoader
6-
from rich.prompt import Prompt
7-
8-
TEMPLATES_DIR = pathlib.Path(__file__).parent.parent / "templates"
9-
10-
11-
class Template(str, enum.Enum):
12-
rigging_basic = "rigging_basic"
13-
rigging_loop = "rigging_loop"
14-
nerve_basic = "nerve_basic"
15-
16-
17-
def template_description(template: Template) -> str:
18-
"""Return the description of a template."""
19-
20-
readme = TEMPLATES_DIR / template.value / "README.md"
21-
if readme.exists():
22-
return readme.read_text()
23-
24-
return ""
25-
26-
27-
def install_template(template: Template, dest: pathlib.Path, context: dict[str, t.Any]) -> None:
28-
"""Install a template into a directory."""
29-
install_template_from_dir(TEMPLATES_DIR / template.value, dest, context)
30-
31-
32-
def install_template_from_dir(src: pathlib.Path, dest: pathlib.Path, context: dict[str, t.Any]) -> None:
33-
"""Install a template from a source directory into a destination directory."""
34-
35-
if not src.exists():
36-
raise Exception(f"Source directory '{src}' does not exist")
37-
38-
elif not src.is_dir():
39-
raise Exception(f"Source '{src}' is not a directory")
40-
41-
# check for Dockerfile in the directory
42-
elif not (src / "Dockerfile").exists() and not (src / "Dockerfile.j2").exists():
43-
raise Exception(f"Source directory {src} does not contain a Dockerfile")
44-
45-
env = Environment(loader=FileSystemLoader(src))
46-
47-
# iterate over all items in the source directory
48-
for src_item in src.glob("**/*"):
49-
# get the relative path of the item
50-
src_item_path = str(src_item.relative_to(src))
51-
# get the destination path
52-
dest_item = dest / src_item_path
53-
54-
# if the destination item is not the root directory and it exists,
55-
# ask the user if they want to overwrite it
56-
if dest_item != dest and dest_item.exists():
57-
if Prompt.ask(f":axe: Overwrite {dest_item}?", choices=["y", "n"], default="n") == "n":
58-
continue
59-
60-
# if the source item is a file
61-
if src_item.is_file():
62-
# if the file has a .j2 extension, render it using Jinja2
63-
if src_item.name.endswith(".j2"):
64-
# we can read as text
65-
content = src_item.read_text()
66-
j2_template = env.get_template(src_item_path)
67-
content = j2_template.render(context)
68-
dest_item = dest / src_item_path.removesuffix(".j2")
69-
dest_item.write_text(content)
70-
else:
71-
# otherwise, copy the file as is
72-
dest_item.write_bytes(src_item.read_bytes())
73-
74-
# if the source item is a directory, create it in the destination
75-
elif src_item.is_dir():
76-
dest_item.mkdir(exist_ok=True)
3+
__all__ = ["cli"]

0 commit comments

Comments
 (0)