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.
- agent_smith/__init__.py +1 -0
- agent_smith/__main__.py +37 -0
- agent_smith/adapters/__init__.py +1 -0
- agent_smith/adapters/adr.py +27 -0
- agent_smith/adapters/cli.py +58 -0
- agent_smith/adapters/configuration.py +186 -0
- agent_smith/adapters/filesystem.py +33 -0
- agent_smith/adapters/just_help.py +45 -0
- agent_smith/adapters/markdown.py +35 -0
- agent_smith/adapters/mise.py +44 -0
- agent_smith/adapters/process.py +30 -0
- agent_smith/application/__init__.py +1 -0
- agent_smith/application/generation.py +109 -0
- agent_smith/application/ports.py +98 -0
- agent_smith_cli-0.3.1.dist-info/METADATA +303 -0
- agent_smith_cli-0.3.1.dist-info/RECORD +19 -0
- agent_smith_cli-0.3.1.dist-info/WHEEL +4 -0
- agent_smith_cli-0.3.1.dist-info/entry_points.txt +3 -0
- agent_smith_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
agent_smith/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Build agent instructions from project sources."""
|
agent_smith/__main__.py
ADDED
|
@@ -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
|
+
[](https://github.com/Mehdi-H/agent-smith/actions/workflows/release.yml?query=branch%3Amain)
|
|
25
|
+
[](pyproject.toml)
|
|
26
|
+
[](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
|
+

|
|
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,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.
|