agent-smith-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Build agent instructions from project sources."""
@@ -0,0 +1,37 @@
1
+ """Composition root for the installed console script and python -m invocation."""
2
+
3
+ from collections.abc import Sequence
4
+ from importlib.metadata import version
5
+
6
+ from agent_smith.adapters.adr import AdrListParser
7
+ from agent_smith.adapters.cli import run
8
+ from agent_smith.adapters.configuration import TomlConfiguration
9
+ from agent_smith.adapters.filesystem import AtomicDocumentWriter, FileTextReader
10
+ from agent_smith.adapters.just_help import JustHelpParser
11
+ from agent_smith.adapters.markdown import MarkdownOverviewParser
12
+ from agent_smith.adapters.mise import MiseTechStackParser
13
+ from agent_smith.adapters.process import ShellCommandRunner
14
+ from agent_smith.application.generation import GenerationService
15
+
16
+
17
+ def main(argv: Sequence[str] | None = None) -> int:
18
+ """Wire concrete adapters into the application and incoming CLI boundary."""
19
+ generator = GenerationService(
20
+ FileTextReader(),
21
+ MarkdownOverviewParser(),
22
+ ShellCommandRunner(),
23
+ AtomicDocumentWriter(),
24
+ JustHelpParser(),
25
+ MiseTechStackParser(),
26
+ AdrListParser(),
27
+ )
28
+ return run(
29
+ argv,
30
+ version=version("agent-smith-cli"),
31
+ configuration=TomlConfiguration(),
32
+ generator=generator,
33
+ )
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Adapters that connect the application to external interfaces."""
@@ -0,0 +1,27 @@
1
+ """Condense adr list paths into a directory and literal extensionless filenames."""
2
+
3
+ from pathlib import PurePosixPath
4
+
5
+ from agent_smith.application.generation import inline_code
6
+ from agent_smith.application.ports import GenerationError
7
+
8
+
9
+ def decision_stem(line: str, directory: str) -> str:
10
+ path = PurePosixPath(line)
11
+ if path.parent != PurePosixPath(directory) or path.suffix != ".md":
12
+ raise GenerationError(f"Unexpected adr list path {line!r} for directory {directory!r}.")
13
+ if any(ord(char) < 32 for char in line) or not path.stem:
14
+ raise GenerationError("ADR filenames must be nonempty and on one line.")
15
+ return path.stem
16
+
17
+
18
+ class AdrListParser:
19
+ def render(self, directory: str, listing: str) -> str:
20
+ directory = directory.strip()
21
+ if not directory or any(ord(char) < 32 for char in directory):
22
+ raise GenerationError(".adr-dir must contain a nonempty directory on one line.")
23
+ paths = [line for line in listing.splitlines() if line.strip()]
24
+ if not paths:
25
+ raise GenerationError("adr list returned no architecture decisions.")
26
+ bullets = [f"- {inline_code(decision_stem(line, directory))}" for line in paths]
27
+ return f"Directory: {inline_code(directory)}\n\n" + "\n".join(bullets)
@@ -0,0 +1,58 @@
1
+ """Argparse adapter; console behavior belongs outside the application core."""
2
+
3
+ import argparse
4
+ import shlex
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from dataclasses import replace
8
+
9
+ from agent_smith.application.ports import Configuration, GenerationError, Generator
10
+
11
+
12
+ def run(
13
+ argv: Sequence[str] | None, *, version: str, configuration: Configuration, generator: Generator
14
+ ) -> int:
15
+ """Translate CLI options into an application request through incoming ports."""
16
+ parser = argparse.ArgumentParser(
17
+ prog="agent-smith",
18
+ description="Build agent instructions from your project's sources.",
19
+ epilog="Detects README.md, mise.toml, justfile and .adr-dir; see agent-smith.toml.",
20
+ allow_abbrev=False,
21
+ )
22
+ parser.add_argument("--version", action="version", version=f"%(prog)s {version}")
23
+ parser.add_argument("--config", metavar="PATH", help="Read this TOML configuration file.")
24
+ parser.add_argument(
25
+ "--output", metavar="PATH", help="Override the output path (default: AGENTS.md)."
26
+ )
27
+ parser.add_argument(
28
+ "--no-overview", action="store_true", help="Disable the built-in README overview."
29
+ )
30
+ parser.add_argument(
31
+ "--no-available-commands",
32
+ action="store_true",
33
+ help="Disable the built-in just help section.",
34
+ )
35
+ parser.add_argument(
36
+ "--no-tech-stack", action="store_true", help="Disable the built-in mise.toml tech stack."
37
+ )
38
+ parser.add_argument(
39
+ "--no-architecture-decisions",
40
+ action="store_true",
41
+ help="Disable the built-in adr list section.",
42
+ )
43
+ invocation = list(sys.argv[1:] if argv is None else argv)
44
+ arguments = parser.parse_args(invocation)
45
+ try:
46
+ request = configuration.load(
47
+ arguments.config,
48
+ output=arguments.output,
49
+ no_overview=arguments.no_overview,
50
+ no_available_commands=arguments.no_available_commands,
51
+ no_tech_stack=arguments.no_tech_stack,
52
+ no_architecture_decisions=arguments.no_architecture_decisions,
53
+ )
54
+ generator.generate(replace(request, command=shlex.join(["agent-smith", *invocation])))
55
+ except GenerationError as error:
56
+ print(f"agent-smith: {error}", file=sys.stderr)
57
+ return 1
58
+ return 0
@@ -0,0 +1,186 @@
1
+ """Load optional TOML configuration and apply explicit CLI overrides."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import cast
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ import tomli as tomllib
11
+
12
+ from agent_smith.application.ports import (
13
+ ArchitectureDecisionsSection,
14
+ AvailableCommandsSection,
15
+ CommandSection,
16
+ GenerationError,
17
+ GenerationRequest,
18
+ OverviewSection,
19
+ Section,
20
+ TechStackSection,
21
+ )
22
+
23
+
24
+ def table(value: object, allowed: set[str], name: str) -> dict[str, object]:
25
+ if not isinstance(value, dict):
26
+ raise GenerationError(f"{name} must be a TOML table.")
27
+ result = cast(dict[str, object], value)
28
+ unknown = result.keys() - allowed
29
+ if unknown:
30
+ raise GenerationError(f"Unknown {name} settings: {', '.join(sorted(unknown))}.")
31
+ return result
32
+
33
+
34
+ def text(value: object, name: str) -> str:
35
+ if not isinstance(value, str) or not value.strip() or any(c in value for c in "\r\n\0"):
36
+ raise GenerationError(f"{name} must be a nonempty string on one line.")
37
+ return value
38
+
39
+
40
+ def overview_sections(
41
+ value: object, destination: str, no_overview: bool, directory: Path
42
+ ) -> list[Section]:
43
+ overview = table(value, {"enabled", "source", "title"}, "overview")
44
+ enabled = overview.get("enabled", True)
45
+ if not isinstance(enabled, bool):
46
+ raise GenerationError("overview.enabled must be a boolean.")
47
+ sections: list[Section] = []
48
+ if enabled and not no_overview:
49
+ readme = text(overview.get("source", "README.md"), "overview.source")
50
+ if (directory / readme).resolve() == (directory / destination).resolve():
51
+ raise GenerationError("The output must not overwrite the overview source.")
52
+ sections.append(
53
+ OverviewSection(text(overview.get("title", "Overview"), "overview.title"), readme)
54
+ )
55
+ return sections
56
+
57
+
58
+ class TomlConfiguration:
59
+ def __init__(self, directory: Path | None = None) -> None:
60
+ self.directory = directory if directory is not None else Path.cwd()
61
+
62
+ def load(
63
+ self,
64
+ path: str | None,
65
+ *,
66
+ output: str | None,
67
+ no_overview: bool,
68
+ no_available_commands: bool = False,
69
+ no_tech_stack: bool = False,
70
+ no_architecture_decisions: bool = False,
71
+ ) -> GenerationRequest:
72
+ source = self.directory / (path or "agent-smith.toml")
73
+ data: dict[str, object] = {}
74
+ try:
75
+ if path is not None or source.exists():
76
+ with source.open("rb") as stream:
77
+ data = tomllib.load(stream)
78
+ except (OSError, ValueError) as error:
79
+ raise GenerationError(f"Cannot load configuration {str(source)!r}: {error}") from error
80
+ data = table(
81
+ data,
82
+ {
83
+ "output",
84
+ "overview",
85
+ "available_commands",
86
+ "tech_stack",
87
+ "architecture_decisions",
88
+ "sections",
89
+ },
90
+ "configuration",
91
+ )
92
+ destination = text(
93
+ output if output is not None else data.get("output", "AGENTS.md"), "output"
94
+ )
95
+ sections = overview_sections(
96
+ data.get("overview", {}), destination, no_overview, self.directory
97
+ )
98
+ sections.extend(
99
+ tech_stack_sections(
100
+ data.get("tech_stack", {}), self.directory, destination, no_tech_stack
101
+ )
102
+ )
103
+ sections.extend(
104
+ available_commands_sections(
105
+ data.get("available_commands", {}), self.directory, no_available_commands
106
+ )
107
+ )
108
+ sections.extend(
109
+ architecture_decisions_sections(
110
+ data.get("architecture_decisions", {}),
111
+ self.directory,
112
+ destination,
113
+ no_architecture_decisions,
114
+ )
115
+ )
116
+ sections.extend(custom_sections(data.get("sections", [])))
117
+ return GenerationRequest(destination, tuple(sections))
118
+
119
+
120
+ def custom_sections(custom: object) -> list[Section]:
121
+ sections: list[Section] = []
122
+ if not isinstance(custom, list):
123
+ raise GenerationError("sections must be an array of TOML tables.")
124
+ for index, item in enumerate(custom):
125
+ section = table(item, {"title", "command"}, f"sections[{index}]")
126
+ sections.append(
127
+ CommandSection(
128
+ text(section.get("title"), "section.title"),
129
+ text(section.get("command"), "section.command"),
130
+ )
131
+ )
132
+ return sections
133
+
134
+
135
+ def available_commands_sections(value: object, directory: Path, disabled: bool) -> list[Section]:
136
+ settings = table(value, {"enabled", "title", "command"}, "available_commands")
137
+ detected = any(
138
+ path.is_file() and path.name.lower() in {"justfile", ".justfile"}
139
+ for path in directory.iterdir()
140
+ )
141
+ enabled = settings.get("enabled", detected)
142
+ if not isinstance(enabled, bool):
143
+ raise GenerationError("available_commands.enabled must be a boolean.")
144
+ if disabled or not enabled:
145
+ return []
146
+ return [
147
+ AvailableCommandsSection(
148
+ text(settings.get("title", "Available commands"), "available_commands.title"),
149
+ text(settings.get("command", "just help"), "available_commands.command"),
150
+ )
151
+ ]
152
+
153
+
154
+ def tech_stack_sections(
155
+ value: object, directory: Path, destination: str, disabled: bool
156
+ ) -> list[Section]:
157
+ settings = table(value, {"enabled", "title", "source"}, "tech_stack")
158
+ source = text(settings.get("source", "mise.toml"), "tech_stack.source")
159
+ enabled = settings.get("enabled", (directory / source).is_file())
160
+ if not isinstance(enabled, bool):
161
+ raise GenerationError("tech_stack.enabled must be a boolean.")
162
+ if disabled or not enabled:
163
+ return []
164
+ if (directory / source).resolve() == (directory / destination).resolve():
165
+ raise GenerationError("The output must not overwrite the tech stack source.")
166
+ return [
167
+ TechStackSection(text(settings.get("title", "Main tech stack"), "tech_stack.title"), source)
168
+ ]
169
+
170
+
171
+ def architecture_decisions_sections(
172
+ value: object, directory: Path, destination: str, disabled: bool
173
+ ) -> list[Section]:
174
+ settings = table(value, {"enabled", "title"}, "architecture_decisions")
175
+ enabled = settings.get("enabled", (directory / ".adr-dir").is_file())
176
+ if not isinstance(enabled, bool):
177
+ raise GenerationError("architecture_decisions.enabled must be a boolean.")
178
+ if disabled or not enabled:
179
+ return []
180
+ if (directory / ".adr-dir").resolve() == (directory / destination).resolve():
181
+ raise GenerationError("The output must not overwrite .adr-dir.")
182
+ return [
183
+ ArchitectureDecisionsSection(
184
+ text(settings.get("title", "Architecture decisions"), "architecture_decisions.title")
185
+ )
186
+ ]
@@ -0,0 +1,33 @@
1
+ """UTF-8 input and atomic output adapters."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile
6
+
7
+ from agent_smith.application.ports import GenerationError
8
+
9
+
10
+ class FileTextReader:
11
+ def read(self, path: str) -> str:
12
+ try:
13
+ return Path(path).read_text(encoding="utf-8-sig")
14
+ except (OSError, UnicodeError) as error:
15
+ raise GenerationError(f"Cannot read {path!r}: {error}") from error
16
+
17
+
18
+ class AtomicDocumentWriter:
19
+ def write(self, path: str, content: str) -> None:
20
+ destination = Path(path)
21
+ temporary: Path | None = None
22
+ try:
23
+ with NamedTemporaryFile(
24
+ mode="w", encoding="utf-8", newline="\n", dir=destination.parent, delete=False
25
+ ) as stream:
26
+ temporary = Path(stream.name)
27
+ stream.write(content)
28
+ os.replace(temporary, destination)
29
+ except (OSError, UnicodeError) as error:
30
+ raise GenerationError(f"Cannot write {path!r}: {error}") from error
31
+ finally:
32
+ if temporary is not None:
33
+ temporary.unlink(missing_ok=True)
@@ -0,0 +1,45 @@
1
+ """Interpret the standard just --list output produced by a project's help recipe."""
2
+
3
+ import re
4
+
5
+ from agent_smith.application.generation import heading, inline_code
6
+ from agent_smith.application.ports import GenerationError
7
+
8
+ ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
9
+ DESCRIPTION = re.compile(r"""("(?:\\.|[^"\\])*"|'[^']*')|(\s+#\s?)""")
10
+ RECIPE = re.compile(r"[\w-]+(?:::[\w-]+)*(?:\s+.*)?\Z")
11
+
12
+
13
+ def split_description(line: str) -> tuple[str, str]:
14
+ for match in DESCRIPTION.finditer(line):
15
+ if match.group(2):
16
+ return line[: match.start()].rstrip(), line[match.end() :]
17
+ return line, ""
18
+
19
+
20
+ def render_recipe(line: str) -> str:
21
+ signature, description = split_description(line)
22
+ if not RECIPE.fullmatch(signature):
23
+ raise GenerationError(f"Unsupported just help recipe: {line!r}.")
24
+ suffix = f" — {description}" if description else ""
25
+ return f"- {inline_code('just ' + signature)}{suffix}"
26
+
27
+
28
+ class JustHelpParser:
29
+ def render(self, output: str) -> str:
30
+ lines = ANSI.sub("", output).splitlines()
31
+ lines = [line.rstrip() for line in lines if line.strip()]
32
+ if not lines or lines[0].strip() != "Available recipes:":
33
+ raise GenerationError("Expected just help to print the standard just --list output.")
34
+ parts = [self.render_line(line) for line in lines[1:]]
35
+ if not any(part.startswith("- ") for part in parts):
36
+ raise GenerationError("just help did not list any available commands.")
37
+ return "\n".join(parts).strip()
38
+
39
+ def render_line(self, line: str) -> str:
40
+ if not line[0].isspace():
41
+ raise GenerationError(f"Unsupported just help output: {line!r}.")
42
+ stripped = line.strip()
43
+ if stripped.startswith("[") and stripped.endswith("]"):
44
+ return f"\n### {heading(stripped[1:-1])}\n"
45
+ return render_recipe(stripped)
@@ -0,0 +1,35 @@
1
+ """Locate CommonMark headings while preserving the original Markdown body."""
2
+
3
+ from markdown_it import MarkdownIt
4
+
5
+ from agent_smith.application.generation import content_lines
6
+ from agent_smith.application.ports import GenerationError
7
+
8
+
9
+ class MarkdownOverviewParser:
10
+ def extract(self, markdown: str) -> str:
11
+ """Extract after the first top-level H1 and before the following H2."""
12
+ markdown = markdown.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
13
+ headings = [
14
+ (token.tag, token.map)
15
+ for token in MarkdownIt("commonmark").parse(markdown)
16
+ if token.type == "heading_open" and token.level == 0 and token.map is not None
17
+ ]
18
+ start, end = overview_bounds(headings, len(markdown.split("\n")))
19
+ body = content_lines("\n".join(markdown.split("\n")[start:end]))
20
+ if not body:
21
+ raise GenerationError("README overview is empty between its H1 and first H2.")
22
+ return body
23
+
24
+
25
+ def overview_bounds(headings: list[tuple[str, list[int]]], end: int) -> tuple[int, int]:
26
+ first = next((index for index, (tag, _) in enumerate(headings) if tag == "h1"), None)
27
+ if first is None:
28
+ raise GenerationError("README overview requires a top-level Markdown H1.")
29
+ start = headings[first][1][1]
30
+ for tag, lines in headings[first + 1 :]:
31
+ if tag == "h2":
32
+ return start, lines[0]
33
+ if tag == "h1":
34
+ raise GenerationError("README has another H1 before its first overview-ending H2.")
35
+ return start, end
@@ -0,0 +1,44 @@
1
+ """Render the declared mise tools without running mise or resolving versions."""
2
+
3
+ import sys
4
+
5
+ if sys.version_info >= (3, 11):
6
+ import tomllib
7
+ else:
8
+ import tomli as tomllib
9
+
10
+ from agent_smith.application.generation import inline_code
11
+ from agent_smith.application.ports import GenerationError
12
+
13
+
14
+ def literal(value: object, name: str) -> str:
15
+ if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
16
+ raise GenerationError(f"{name} must be a nonempty, single-line string.")
17
+ return inline_code(value)
18
+
19
+
20
+ def version_label(value: object, name: str) -> str:
21
+ if isinstance(value, dict):
22
+ value = value.get("version")
23
+ return literal(value, f"tools.{name}.version")
24
+
25
+
26
+ def tool_versions(value: object, name: str) -> str:
27
+ versions = value if isinstance(value, list) else [value]
28
+ if not versions:
29
+ raise GenerationError(f"tools.{name} must declare at least one version.")
30
+ return ", ".join(version_label(version, name) for version in versions)
31
+
32
+
33
+ class MiseTechStackParser:
34
+ def render(self, content: str) -> str:
35
+ try:
36
+ tools = tomllib.loads(content).get("tools", {})
37
+ except ValueError as error:
38
+ raise GenerationError(f"Invalid mise TOML: {error}") from error
39
+ if not isinstance(tools, dict) or not tools:
40
+ raise GenerationError("Expected a nonempty [tools] table in mise.toml.")
41
+ return "\n".join(
42
+ f"- {literal(name, 'Tool name')} — {tool_versions(value, name)}"
43
+ for name, value in tools.items()
44
+ )
@@ -0,0 +1,30 @@
1
+ """Run explicitly configured commands with the platform shell."""
2
+
3
+ import subprocess
4
+
5
+ from agent_smith.application.ports import GenerationError
6
+
7
+
8
+ class ShellCommandRunner:
9
+ def __init__(self, timeout: float = 30) -> None:
10
+ self.timeout = timeout
11
+
12
+ def run(self, command: str) -> str:
13
+ try:
14
+ result = subprocess.run(
15
+ command,
16
+ # Custom extractors are explicitly trusted shell commands, not data inputs.
17
+ shell=True, # nosec B602
18
+ capture_output=True,
19
+ text=True,
20
+ encoding="utf-8",
21
+ timeout=self.timeout,
22
+ )
23
+ except (OSError, UnicodeError, subprocess.TimeoutExpired) as error:
24
+ raise GenerationError(f"Cannot execute {command!r}: {error}") from error
25
+ if result.returncode:
26
+ diagnostic = result.stderr or result.stdout
27
+ raise GenerationError(
28
+ f"Command failed (exit {result.returncode}): {command}\n{diagnostic.rstrip()}"
29
+ )
30
+ return result.stdout
@@ -0,0 +1 @@
1
+ """Application requests, ports and generation rules."""
@@ -0,0 +1,109 @@
1
+ """Render all sections successfully before writing a document."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import PurePath
6
+
7
+ from agent_smith.application.ports import (
8
+ ArchitectureDecisionsSection,
9
+ AvailableCommandsSection,
10
+ CommandRunner,
11
+ CommandSection,
12
+ DecisionListParser,
13
+ DocumentWriter,
14
+ GenerationError,
15
+ GenerationRequest,
16
+ HelpParser,
17
+ OverviewParser,
18
+ OverviewSection,
19
+ Section,
20
+ TechStackParser,
21
+ TechStackSection,
22
+ TextReader,
23
+ )
24
+
25
+
26
+ def content_lines(content: str) -> str:
27
+ """Normalize line endings and remove only surrounding blank lines."""
28
+ lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
29
+ while lines and not lines[0].strip():
30
+ lines.pop(0)
31
+ while lines and not lines[-1].strip():
32
+ lines.pop()
33
+ return "\n".join(lines)
34
+
35
+
36
+ def inline_code(value: str) -> str:
37
+ """Keep literal backticks and edge spaces inside a Markdown code span."""
38
+ fence = "`" * (max((len(run) for run in re.findall(r"`+", value)), default=0) + 1)
39
+ padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else ""
40
+ return f"{fence}{padding}{value}{padding}{fence}"
41
+
42
+
43
+ def heading(value: str) -> str:
44
+ """Escape Markdown punctuation so the configured title stays one heading."""
45
+ return "".join("\\" + char if char in "\\`*_[]<>#" else char for char in value)
46
+
47
+
48
+ def validate_request(request: GenerationRequest) -> None:
49
+ if not request.output.strip() or "\n" in request.output or "\r" in request.output:
50
+ raise GenerationError("The output path must be nonempty and on one line.")
51
+ if not request.sections:
52
+ raise GenerationError("No sections enabled; enable overview or configure a section.")
53
+ for section in request.sections:
54
+ if not section.title.strip() or any(c in section.title for c in "\r\n"):
55
+ raise GenerationError("Section titles must be nonempty and on one line.")
56
+
57
+
58
+ @dataclass
59
+ class GenerationService:
60
+ reader: TextReader
61
+ overview: OverviewParser
62
+ commands: CommandRunner
63
+ writer: DocumentWriter
64
+ help_parser: HelpParser
65
+ tech_stack: TechStackParser
66
+ decisions: DecisionListParser
67
+
68
+ def generate(self, request: GenerationRequest) -> None:
69
+ """Generate one deterministic document via injected effect boundaries."""
70
+ validate_request(request)
71
+ parts = [f"# {heading(PurePath(request.output).name)}"]
72
+ for section in request.sections:
73
+ body, provenance = self.render_section(section, request.command)
74
+ parts.append(f"## {heading(section.title)}\n\n{body}\n\n> {provenance}")
75
+ self.writer.write(request.output, "\n\n".join(parts) + "\n")
76
+
77
+ def render_section(self, section: Section, invocation: str) -> tuple[str, str]:
78
+ body, command = self.section_content(section, invocation)
79
+ provenance = (
80
+ "*This section was automatically generated by executing the command: "
81
+ f"**{inline_code(command)}**.*"
82
+ )
83
+ body = content_lines(body)
84
+ if not body:
85
+ raise GenerationError(f"Section {section.title!r} produced no content.")
86
+ return body, provenance
87
+
88
+ def section_content(self, section: Section, invocation: str) -> tuple[str, str]:
89
+ if isinstance(section, OverviewSection):
90
+ return self.overview.extract(self.reader.read(section.source)), invocation
91
+ if isinstance(section, TechStackSection):
92
+ try:
93
+ return self.tech_stack.render(self.reader.read(section.source)), invocation
94
+ except GenerationError as error:
95
+ raise GenerationError(f"Tech stack source {section.source!r}: {error}") from error
96
+ if isinstance(section, ArchitectureDecisionsSection):
97
+ directory = self.reader.read(".adr-dir")
98
+ return self.decisions.render(directory, self.commands.run("adr list")), "adr list"
99
+ return self.command_content(section)
100
+
101
+ def command_content(
102
+ self, section: AvailableCommandsSection | CommandSection
103
+ ) -> tuple[str, str]:
104
+ if not section.command.strip() or any(c in section.command for c in "\r\n"):
105
+ raise GenerationError("Section commands must be nonempty and on one line.")
106
+ body = self.commands.run(section.command)
107
+ if isinstance(section, AvailableCommandsSection):
108
+ body = self.help_parser.render(body)
109
+ return body, section.command
@@ -0,0 +1,98 @@
1
+ """Framework-independent contracts for generating an instruction document."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+
7
+ class GenerationError(Exception):
8
+ """An actionable failure that must leave the existing document untouched."""
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class OverviewSection:
13
+ title: str = "Overview"
14
+ source: str = "README.md"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AvailableCommandsSection:
19
+ title: str = "Available commands"
20
+ command: str = "just help"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TechStackSection:
25
+ title: str = "Main tech stack"
26
+ source: str = "mise.toml"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ArchitectureDecisionsSection:
31
+ title: str = "Architecture decisions"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CommandSection:
36
+ title: str
37
+ command: str
38
+
39
+
40
+ Section = (
41
+ OverviewSection
42
+ | AvailableCommandsSection
43
+ | TechStackSection
44
+ | ArchitectureDecisionsSection
45
+ | CommandSection
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class GenerationRequest:
51
+ output: str = "AGENTS.md"
52
+ sections: tuple[Section, ...] = (OverviewSection(),)
53
+ command: str = "agent-smith"
54
+
55
+
56
+ class Generator(Protocol):
57
+ def generate(self, request: GenerationRequest) -> None: ...
58
+
59
+
60
+ class Configuration(Protocol):
61
+ def load(
62
+ self,
63
+ path: str | None,
64
+ *,
65
+ output: str | None,
66
+ no_overview: bool,
67
+ no_available_commands: bool = False,
68
+ no_tech_stack: bool = False,
69
+ no_architecture_decisions: bool = False,
70
+ ) -> GenerationRequest: ...
71
+
72
+
73
+ class TextReader(Protocol):
74
+ def read(self, path: str) -> str: ...
75
+
76
+
77
+ class OverviewParser(Protocol):
78
+ def extract(self, markdown: str) -> str: ...
79
+
80
+
81
+ class HelpParser(Protocol):
82
+ def render(self, output: str) -> str: ...
83
+
84
+
85
+ class TechStackParser(Protocol):
86
+ def render(self, content: str, /) -> str: ...
87
+
88
+
89
+ class DecisionListParser(Protocol):
90
+ def render(self, directory: str, listing: str) -> str: ...
91
+
92
+
93
+ class CommandRunner(Protocol):
94
+ def run(self, command: str) -> str: ...
95
+
96
+
97
+ class DocumentWriter(Protocol):
98
+ def write(self, path: str, content: str) -> None: ...
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-smith-cli
3
+ Version: 0.3.1
4
+ Summary: Build agent instructions from your project's sources.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: markdown-it-py>=4,<5
16
+ Requires-Dist: tomli>=2 ; python_full_version < '3.11'
17
+ Requires-Python: >=3.10
18
+ Project-URL: Repository, https://github.com/Mehdi-H/agent-smith
19
+ Project-URL: Issues, https://github.com/Mehdi-H/agent-smith/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Agent Smith 🕶️
23
+
24
+ [![CI](https://github.com/Mehdi-H/agent-smith/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/Mehdi-H/agent-smith/actions/workflows/release.yml?query=branch%3Amain)
25
+ [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-blue?logo=python&logoColor=white)](pyproject.toml)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
27
+
28
+ *Smith* your AGENTS.md file 🕶️
29
+
30
+ Build agent instructions from your project's sources !
31
+
32
+ Treat your agent instructions as **living documentation**: regenerate them from
33
+ the sources you maintain as your project evolves.
34
+
35
+ Run `agent-smith` at your project root to generate **`AGENTS.md`** from your
36
+ README overview, your mise tool declarations, documented just commands,
37
+ architecture decision filenames and optional custom extractors
38
+
39
+ Forget `/init` skill, the output is _deterministic_, repeatable Markdown, you stay in control
40
+
41
+ ## Demo
42
+
43
+ > [!NOTE]
44
+ > The CLI is a development preview. Built-in overview, tech-stack, command and ADR
45
+ > sections are available; no package release has been published yet.
46
+
47
+
48
+ ![agent-smith creating AGENTS.md on the left, with a live Glow preview on the right](docs/demo/agent-smith.gif)
49
+
50
+ ## Install
51
+
52
+ The PyPI distribution is named **`agent-smith-cli`**; the executable remains
53
+ `agent-smith`. The name `agent-smith` was already taken on PyPI.
54
+
55
+ Agent Smith supports Python 3.10–3.14. No release is available on PyPI yet:
56
+ start from a checkout of this version of the repository and install the CLI
57
+ from its root directory with uv:
58
+
59
+ ```sh
60
+ uv tool install .
61
+ ```
62
+
63
+ This installs the command in an isolated environment.
64
+
65
+ > [!TIP]
66
+ > If uv reports that its tool directory is missing from your PATH, run
67
+ > `uv tool update-shell` and restart your terminal.
68
+
69
+ Alternatively, install with pip in an activated Python virtual environment:
70
+
71
+ ```sh
72
+ python -m pip install .
73
+ ```
74
+
75
+ ## Run and verify
76
+
77
+ ```sh
78
+ agent-smith --version
79
+ agent-smith --help
80
+ agent-smith
81
+ ```
82
+
83
+ > [!TIP]
84
+ > To confirm installation, check that `--version` prints the installed Agent Smith version
85
+ > and `--help` displays the available options. Both should exit successfully.
86
+
87
+ Run the command **from the root of the project you want to document**. It creates
88
+ or replaces `AGENTS.md` in that directory. Successful generation is silent;
89
+ open the file to verify the result. No configuration is needed when you follow
90
+ the conventions below.
91
+
92
+ ```sh
93
+ agent-smith
94
+ cat AGENTS.md
95
+ ```
96
+
97
+ Use `agent-smith --output instructions.md` to choose another output filename.
98
+ The document has an H1 containing its filename, an H2 for each section and a
99
+ quoted footer identifying the command that produced that section.
100
+
101
+ ## Conventions
102
+
103
+ ### Overview: a README.md at the project root
104
+
105
+ Place a `README.md` at the root with a top-level H1 followed by a nonempty
106
+ introduction. Agent Smith copies the Markdown between that H1 and the first
107
+ following H2 into **Overview**. No extraction script is required.
108
+
109
+ ```markdown
110
+ # My project
111
+
112
+ Describe what the project does and why someone would use it.
113
+
114
+ ## Installation
115
+
116
+ This section is outside the extracted overview.
117
+ ```
118
+
119
+ The first H2 ends the overview; if there is no H2, extraction continues to the
120
+ end of the file. Badges, links and GitHub alerts in the introduction are kept.
121
+ An absent README, a missing H1 or an empty introduction produces an error.
122
+ Use `--no-overview` to disable this section.
123
+
124
+ ### Main tech stack: declared tools in a root mise.toml
125
+
126
+ Put a `mise.toml` at your project root with a nonempty `[tools]` table:
127
+
128
+ ```toml
129
+ [tools]
130
+ python = ["3.14", "3.10"]
131
+ uv = "latest"
132
+ node = { version = "lts", postinstall = "corepack enable" }
133
+ ```
134
+
135
+ Agent Smith automatically adds:
136
+
137
+ ```markdown
138
+ ## Main tech stack
139
+
140
+ - `python` — `3.14`, `3.10`
141
+ - `uv` — `latest`
142
+ - `node` — `lts`
143
+ ```
144
+
145
+ Tool names (including backend prefixes) and declared versions stay in file order.
146
+ Strings, arrays of versions, and tables with a string `version` are supported,
147
+ including arrays of those tables. Installation options are ignored. Agent Smith
148
+ reads TOML directly: mise need not be installed, no hooks or templates execute,
149
+ and aliases such as `latest` remain literal. It does not resolve installed versions,
150
+ merge global/local configuration, or inspect other files such as `.python-version`.
151
+
152
+ With no root `mise.toml`, this section is omitted. Use `--no-tech-stack` or
153
+ `[tech_stack].enabled = false` to disable it. In the tool's configuration, `source`
154
+ selects another TOML file and `title` changes the heading. An explicit
155
+ `enabled = true` requires that source to exist. Invalid TOML, an empty `[tools]`
156
+ table or an unsupported version declaration fails generation and preserves the
157
+ existing document. The footer names the exact `agent-smith` invocation.
158
+
159
+ ### Available commands: a documented, grouped justfile at the project root
160
+
161
+ Document your project's practices in a root `justfile` (also detected as
162
+ `Justfile` or `.justfile`). Give each recipe a descriptive comment and a group,
163
+ and provide a `help` recipe that prints the standard `just --list` output:
164
+
165
+ ```just
166
+ # List the project's available commands.
167
+ [group("Help")]
168
+ help:
169
+ @just --list
170
+
171
+ # Check modified files for whitespace errors.
172
+ [group("Quality")]
173
+ check-whitespace:
174
+ git diff --check
175
+ ```
176
+
177
+ [Install just](https://just.systems/man/en/installation.html) and make sure
178
+ `just help` works from the project root. Agent Smith automatically runs that
179
+ command and converts its output to **Available commands**: Markdown lists under
180
+ group subheadings, preserving recipe order, parameters and descriptions.
181
+ For the example above, the section contains:
182
+
183
+ ```markdown
184
+ ## Available commands
185
+
186
+ ### Help
187
+
188
+ - `just help` — List the project's available commands.
189
+
190
+ ### Quality
191
+
192
+ - `just check-whitespace` — Check modified files for whitespace errors.
193
+ ```
194
+
195
+ The section ends with a footer naming `just help`. Listed recipes are not
196
+ executed; only the help recipe runs. With no root justfile, this section is
197
+ omitted and just is not required. Use `--no-available-commands` to disable it.
198
+ If help fails or does not produce the supported list format, generation fails
199
+ and the existing `AGENTS.md` is preserved. Custom help formats can be supplied
200
+ as Markdown through a custom section instead.
201
+
202
+ ### Architecture decisions: an ADR directory declared in .adr-dir
203
+
204
+ Use [adr-tools](https://github.com/npryce/adr-tools) and a root `.adr-dir` containing
205
+ the path to your decisions directory, for example `docs/adr`. When that file exists,
206
+ Agent Smith runs `adr list` and adds a compact index:
207
+
208
+ ```markdown
209
+ ## Architecture decisions
210
+
211
+ Directory: `docs/adr`
212
+
213
+ - `0001-record-architecture-decisions`
214
+ - `0002-use-python`
215
+ ```
216
+
217
+ The directory appears once, using the content of `.adr-dir`. Each bullet contains
218
+ only a filename without its final `.md` extension: numbers, hyphens and ordering
219
+ from `adr list` are preserved. ADR contents and their Markdown headings are never
220
+ read. Use meaningful filenames so the index conveys decisions without loading
221
+ individual records. All records listed by adr-tools are included; their status
222
+ is not inferred from their filenames.
223
+
224
+ The footer names `adr list`. The command must be installed and runnable from the
225
+ project root, and its listed paths must match `.adr-dir`. With no `.adr-dir`, this
226
+ section is omitted. Empty or invalid metadata, a failed command or unsupported
227
+ output fails generation while preserving the existing document.
228
+
229
+ Use `--no-architecture-decisions` or `[architecture_decisions].enabled = false`
230
+ to disable this built-in, and `title` to rename its heading. Setting `enabled = true`
231
+ explicitly requires `.adr-dir` even if it was not detected automatically.
232
+
233
+ ## Configure sections
234
+
235
+ An optional root `agent-smith.toml` customizes built-in sections and adds custom
236
+ extractors. For example, to enable the four built-ins and append tracked files:
237
+
238
+ ```toml
239
+ output = "AGENTS.md"
240
+
241
+ [overview]
242
+ enabled = true
243
+ source = "README.md"
244
+ title = "Overview"
245
+
246
+ [available_commands]
247
+ enabled = true
248
+ title = "Available commands"
249
+ command = "just help"
250
+
251
+ [tech_stack]
252
+ enabled = true
253
+ source = "mise.toml"
254
+ title = "Main tech stack"
255
+
256
+ [architecture_decisions]
257
+ enabled = true
258
+ title = "Architecture decisions"
259
+
260
+ [[sections]]
261
+ title = "Tracked files"
262
+ command = "git ls-files"
263
+ ```
264
+
265
+ Each custom section uses its command's UTF-8 stdout as Markdown, followed by a
266
+ footer with the exact command. Sections appear in configuration order after the
267
+ built-in overview, main tech stack, available commands and architecture decisions. Set `[available_commands].enabled = false`
268
+ to disable command discovery, or change its `command` to another source of
269
+ standard just list output, such as `just --list`. Explicit `enabled = true`
270
+ requires the command to work even if no root justfile was detected.
271
+
272
+ To replace the overview with your own extractor:
273
+
274
+ ```toml
275
+ [overview]
276
+ enabled = false
277
+
278
+ [[sections]]
279
+ title = "Overview"
280
+ command = "./scripts/my-overview.sh"
281
+ ```
282
+
283
+ Supply your own script for that command. You can also disable the built-in with
284
+ `--no-overview`, and select another configuration with `--config path/to/config.toml`.
285
+ `--output` takes precedence over configuration. Paths and command working
286
+ directories are relative to where you invoke the CLI, including with `--config`.
287
+ The output's parent directory must exist.
288
+
289
+ > [!WARNING]
290
+ > The help recipe and custom commands run with your permissions. Only generate
291
+ > documents from trusted projects and configurations. If extraction or writing fails, Agent Smith preserves
292
+ > the existing output file; side effects of custom scripts are not rolled back.
293
+
294
+ > [!NOTE]
295
+ > Identical configuration and extractor outputs produce identical Markdown.
296
+ > Variable command output, such as timestamps, remains variable. Extraction
297
+ > preserves relative links and does not copy reference definitions from outside
298
+ > the overview. No Markdown formatter is applied.
299
+
300
+ ## Contributing
301
+
302
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for repository setup, development commands, commit
303
+ conventions and just-in-time architecture decisions. The license is [MIT](LICENSE).
@@ -0,0 +1,19 @@
1
+ agent_smith/__init__.py,sha256=f0XbBiKqIO8OmyVil5cJM8bfKa8Bni3iWKqeescG1Xo,53
2
+ agent_smith/__main__.py,sha256=ldNUGcOTJq7fazejczKvTp5t28Nx8cFrhxsvo8E6Ymw,1294
3
+ agent_smith/adapters/__init__.py,sha256=mosR2as0mLhQ-Cz6zBIbhkJSxPpK5b-qQ60KevieVtk,68
4
+ agent_smith/adapters/adr.py,sha256=QpRAZ1RnwCrNAI3PnqQkp6k41LFRHBXynRY1gGCvPsI,1284
5
+ agent_smith/adapters/cli.py,sha256=0ZJqNptcRzf_NROZomoSLlus9_TRM6psTeJOSxWyVmk,2297
6
+ agent_smith/adapters/configuration.py,sha256=NObBpCXg4IPN7-F88i2YkxjWk_5cZl51l33MZnr-qMY,6884
7
+ agent_smith/adapters/filesystem.py,sha256=nzw7wgkE36n1RWUyWgz1i4gP93oShCM2sUMpatF-3RA,1136
8
+ agent_smith/adapters/just_help.py,sha256=zwlT6hsQ0y1dpykawqdWxTWAQDJoBJEA-ifl9MQC42w,1881
9
+ agent_smith/adapters/markdown.py,sha256=Suk_AUqnt-5-Sz-cDwhTb2ejVgtppPr1wZpoljzFGCg,1552
10
+ agent_smith/adapters/mise.py,sha256=Pb7x0MaEAdyTVdZFBYIizyIYwnTuI9SCKyG1tMistXQ,1569
11
+ agent_smith/adapters/process.py,sha256=RM-rdfEJLW3kAes1uCdHVkJMAQbXbz7AeMAKYIid440,1076
12
+ agent_smith/application/__init__.py,sha256=DF950dowZpQ1kdFeoV1aFKNWhx9PBl0RDibg27_EeCw,56
13
+ agent_smith/application/generation.py,sha256=LzrM9b9IbPbyirnKHVXYerN1wyqFo8apCPy9Wj4ODMQ,4452
14
+ agent_smith/application/ports.py,sha256=Fd4mZcYTLaDsemV6AysvBGpuYmRcctgzAbmsnebYy00,2103
15
+ agent_smith_cli-0.3.1.dist-info/licenses/LICENSE,sha256=YWTquHqMvNvup1vyjr7RgCVb16Tkys8-5KSit_wGcN4,1064
16
+ agent_smith_cli-0.3.1.dist-info/WHEEL,sha256=-i9oRNYVXXZJUIYl5zclLIg6onEb0NLibTX34uln84w,81
17
+ agent_smith_cli-0.3.1.dist-info/entry_points.txt,sha256=aJ37Bi8gUznuQzcifEHRfyUEpJOWyMjHJhabyLqHSzU,59
18
+ agent_smith_cli-0.3.1.dist-info/METADATA,sha256=B7JZrK5MyuEi8_yp7QkpMoZ59zWQHmUC9YV0U3ZU08o,10875
19
+ agent_smith_cli-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.13
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ agent-smith = agent_smith.__main__:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mehdi-H
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.