makefile-agent 0.3.0__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.
make_agent/parser.py ADDED
@@ -0,0 +1,351 @@
1
+ """Makefile parser.
2
+
3
+ Parses a subset of GNU Make syntax into structured data:
4
+ - Variables (=, :=, ?=, +=, and ``define``/``endef`` blocks)
5
+ - Rules (target: prerequisites + tab-indented recipes)
6
+ - .PHONY declarations
7
+ - Special comment blocks:
8
+ # <system> … # </system> — system prompt for the agent (legacy)
9
+ # <tool> … # </tool> — tool description + @param declarations
10
+ - ``define SYSTEM_PROMPT … endef`` — system prompt (preferred; takes
11
+ precedence over ``# <system>`` blocks)
12
+
13
+ Inside a ``# <tool>`` block:
14
+ - Lines starting with ``# @param NAME type description`` declare tool
15
+ parameters.
16
+ - All other lines form the human-readable tool description sent to the LLM.
17
+
18
+ Param types
19
+ -----------
20
+ ``string``, ``number``, ``integer``, ``boolean``
21
+ Standard JSON Schema primitives. Values are injected via a temporary
22
+ ``params.mk`` file using ``define``/``endef`` blocks, so the LLM can pass
23
+ any text — including ``$``, quotes, and newlines — without escaping.
24
+
25
+ Recipes reference parameters as ``$(PARAM)`` (Make-expanded) or
26
+ ``$(value PARAM)`` (raw literal, preserves ``$`` signs and special
27
+ characters).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from enum import Enum, auto
35
+ from pathlib import Path
36
+
37
+ _FLAVOR_MAP = {
38
+ "=": "recursive",
39
+ ":=": "simple",
40
+ "::=": "simple",
41
+ "?=": "conditional",
42
+ "+=": "append",
43
+ }
44
+
45
+ # Matches: NAME = value / NAME := value / NAME ?= value / NAME += value
46
+ _VARIABLE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*(::?=|\?=|\+=|=)\s*(.*)")
47
+
48
+ # Matches: target(s): prerequisites
49
+ # [^\t#] — first char is not a tab or comment marker
50
+ # [^:=]*? — non-greedy, no colons or equals in the target portion
51
+ # :(?![:=]) — a single colon NOT followed by : or =
52
+ _TARGET_RE = re.compile(r"^([^\t#][^:=]*?)\s*:(?![:=])(.*)")
53
+
54
+ # Matches: .PHONY: target …
55
+ _PHONY_RE = re.compile(r"^\.PHONY\s*:(.*)")
56
+
57
+ # Matches: $(VAR), ${VAR}, or $X variable references
58
+ _VAR_REF_RE = re.compile(r"\$(?:\(([^)]+)\)|\{([^}]+)\}|([A-Za-z_]))")
59
+
60
+ # Matches: @param NAME type description (inside a <tool> block)
61
+ _PARAM_RE = re.compile(r"^@param\s+(\w+)\s+(\w+)\s+(.+)")
62
+
63
+
64
+ @dataclass
65
+ class Variable:
66
+ name: str
67
+ value: str
68
+ flavor: str
69
+
70
+
71
+ @dataclass
72
+ class Param:
73
+ name: str
74
+ type: str # JSON Schema primitive: string, number, integer, boolean
75
+ description: str
76
+
77
+
78
+ @dataclass
79
+ class Rule:
80
+ target: str
81
+ prerequisites: list[str] = field(default_factory=list)
82
+ recipes: list[str] = field(default_factory=list)
83
+ is_phony: bool = False
84
+ description: str | None = None
85
+ params: list[Param] = field(default_factory=list)
86
+
87
+
88
+ @dataclass
89
+ class Makefile:
90
+ system_prompt: str | None = None
91
+ variables: dict[str, Variable] = field(default_factory=dict)
92
+ rules: list[Rule] = field(default_factory=list)
93
+ default_target: str | None = None # first non-special target
94
+
95
+
96
+ def _expand_vars(s: str, variables: dict[str, Variable]) -> str:
97
+ """Expand $(VAR), ${VAR}, and $X references using already-seen variables."""
98
+
99
+ def replace(m: re.Match) -> str: # type: ignore[type-arg]
100
+ name = m.group(1) or m.group(2) or m.group(3)
101
+ return variables[name].value if name in variables else m.group(0)
102
+
103
+ return _VAR_REF_RE.sub(replace, s)
104
+
105
+
106
+ def _strip_comment(s: str) -> str:
107
+ """Strip an inline # comment (not preceded by a backslash)."""
108
+ i = 0
109
+ while i < len(s):
110
+ if s[i] == "#" and (i == 0 or s[i - 1] != "\\"):
111
+ return s[:i].rstrip()
112
+ i += 1
113
+ return s.rstrip()
114
+
115
+
116
+ _DEFINE_RE = re.compile(r"^define\s+([A-Za-z_][A-Za-z0-9_]*)\s*$")
117
+
118
+
119
+ class _State(Enum):
120
+ NORMAL = auto()
121
+ RECIPE = auto()
122
+ SYSTEM_BLOCK = auto()
123
+ TOOL_BLOCK = auto()
124
+ DEFINE = auto()
125
+
126
+
127
+ def parse(text: str) -> Makefile:
128
+ """Parse Makefile text and return a structured :class:`Makefile` object."""
129
+ result = Makefile()
130
+ state = _State.NORMAL
131
+ current_recipes: list[str] | None = None
132
+ pending_description: str | None = None
133
+ pending_params: list[Param] = []
134
+ system_lines: list[str] = []
135
+ tool_lines: list[str] = []
136
+ tool_params: list[Param] = []
137
+ phony_targets: set[str] = set()
138
+ define_name: str = ""
139
+ define_lines: list[str] = []
140
+
141
+ # Join line continuations (backslash-newline → space), but only outside
142
+ # define blocks (define block content is literal).
143
+ raw_lines = text.splitlines()
144
+ logical_lines: list[str] = []
145
+ buf = ""
146
+ in_define = False
147
+ for raw in raw_lines:
148
+ stripped_raw = raw.strip()
149
+ if not in_define and _DEFINE_RE.match(stripped_raw):
150
+ if buf:
151
+ logical_lines.append(buf)
152
+ buf = ""
153
+ logical_lines.append(raw)
154
+ in_define = True
155
+ elif in_define:
156
+ logical_lines.append(raw)
157
+ if stripped_raw == "endef":
158
+ in_define = False
159
+ elif raw.endswith("\\"):
160
+ buf += raw[:-1] + " "
161
+ else:
162
+ logical_lines.append(buf + raw)
163
+ buf = ""
164
+ if buf:
165
+ logical_lines.append(buf)
166
+
167
+ for line in logical_lines:
168
+ # ── Inside a define block ────────────────────────────────────────────
169
+ if state == _State.DEFINE:
170
+ if line.strip() == "endef":
171
+ value = "\n".join(define_lines)
172
+ result.variables[define_name] = Variable(name=define_name, value=value, flavor="define")
173
+ if define_name == "SYSTEM_PROMPT":
174
+ result.system_prompt = value.strip() or None
175
+ state = _State.NORMAL
176
+ else:
177
+ define_lines.append(line)
178
+ continue
179
+
180
+ # Recipe lines must start with a tab
181
+ if line.startswith("\t"):
182
+ if state == _State.RECIPE and current_recipes is not None:
183
+ current_recipes.append(line[1:]) # strip the leading tab
184
+ continue
185
+
186
+ # A non-tab line ends recipe collection.
187
+ # Exception: plain comment lines (not special block tags) are ignored by
188
+ # GNU Make inside a rule body and should not end recipe collection.
189
+ if state == _State.RECIPE:
190
+ stripped_peek = line.strip()
191
+ if stripped_peek.startswith("#") and stripped_peek not in ("# <tool>", "# <system>", "# </tool>", "# </system>"):
192
+ continue
193
+ state = _State.NORMAL
194
+ current_recipes = None
195
+
196
+ stripped = line.strip()
197
+
198
+ # ── Inside a special comment block ──────────────────────────────────
199
+ if state == _State.SYSTEM_BLOCK:
200
+ if stripped == "# </system>":
201
+ # Only set system_prompt from # <system> if not already set by define
202
+ if result.system_prompt is None:
203
+ result.system_prompt = "\n".join(system_lines).strip() or None
204
+ state = _State.NORMAL
205
+ elif stripped.startswith("# "):
206
+ system_lines.append(stripped[2:])
207
+ elif stripped == "#":
208
+ system_lines.append("")
209
+ continue
210
+
211
+ if state == _State.TOOL_BLOCK:
212
+ if stripped == "# </tool>":
213
+ pending_description = "\n".join(tool_lines).strip() or None
214
+ pending_params = tool_params
215
+ tool_lines = []
216
+ tool_params = []
217
+ state = _State.NORMAL
218
+ elif stripped.startswith("# "):
219
+ content = stripped[2:]
220
+ param_m = _PARAM_RE.match(content)
221
+ if param_m:
222
+ tool_params.append(Param(
223
+ name=param_m.group(1),
224
+ type=param_m.group(2),
225
+ description=param_m.group(3).strip(),
226
+ ))
227
+ else:
228
+ tool_lines.append(content)
229
+ elif stripped == "#":
230
+ tool_lines.append("")
231
+ continue
232
+
233
+ # ── Block-opening tags ───────────────────────────────────────────────
234
+ if stripped == "# <system>":
235
+ system_lines = []
236
+ state = _State.SYSTEM_BLOCK
237
+ continue
238
+
239
+ if stripped == "# <tool>":
240
+ tool_lines = []
241
+ tool_params = []
242
+ state = _State.TOOL_BLOCK
243
+ continue
244
+
245
+ # ── Skip regular comments and blank lines ────────────────────────────
246
+ if not stripped or stripped.startswith("#"):
247
+ continue
248
+
249
+ # ── define/endef block ───────────────────────────────────────────────
250
+ define_m = _DEFINE_RE.match(stripped)
251
+ if define_m:
252
+ define_name = define_m.group(1)
253
+ define_lines = []
254
+ state = _State.DEFINE
255
+ continue
256
+
257
+ # ── .PHONY declaration ───────────────────────────────────────────────
258
+ phony_m = _PHONY_RE.match(stripped)
259
+ if phony_m:
260
+ new_phonies = phony_m.group(1).split()
261
+ phony_targets.update(new_phonies)
262
+ for rule in result.rules:
263
+ if rule.target in phony_targets:
264
+ rule.is_phony = True
265
+ continue
266
+
267
+ # ── Variable assignment ──────────────────────────────────────────────
268
+ var_m = _VARIABLE_RE.match(line.rstrip())
269
+ if var_m:
270
+ name = var_m.group(1)
271
+ op = var_m.group(2).strip()
272
+ raw_val = _strip_comment(var_m.group(3))
273
+ expanded = _expand_vars(raw_val, result.variables)
274
+ flavor = _FLAVOR_MAP.get(op, "recursive")
275
+ if op == "+=" and name in result.variables:
276
+ result.variables[name].value = (result.variables[name].value + " " + expanded).strip()
277
+ else:
278
+ result.variables[name] = Variable(name=name, value=expanded, flavor=flavor)
279
+ continue
280
+
281
+ # ── Target rule ──────────────────────────────────────────────────────
282
+ target_m = _TARGET_RE.match(line.rstrip())
283
+ if target_m:
284
+ targets_str = target_m.group(1).strip()
285
+ prereqs_str = _strip_comment(target_m.group(2) or "")
286
+ targets = [_expand_vars(t, result.variables) for t in targets_str.split()]
287
+ prereqs = [_expand_vars(p, result.variables) for p in prereqs_str.split()]
288
+ shared_recipes: list[str] = []
289
+ for i, target in enumerate(targets):
290
+ rule = Rule(
291
+ target=target,
292
+ prerequisites=prereqs,
293
+ recipes=shared_recipes,
294
+ is_phony=target in phony_targets,
295
+ description=pending_description if i == 0 else None,
296
+ params=pending_params if i == 0 else [],
297
+ )
298
+ result.rules.append(rule)
299
+ if result.default_target is None and not target.startswith("."):
300
+ result.default_target = target
301
+ pending_description = None
302
+ pending_params = []
303
+ state = _State.RECIPE
304
+ current_recipes = shared_recipes
305
+
306
+ return result
307
+
308
+
309
+ def parse_file(path: str | Path) -> Makefile:
310
+ """Parse a Makefile from disk."""
311
+ return parse(Path(path).read_text(encoding="utf-8"))
312
+
313
+
314
+ # Matches $(NAME), ${NAME}, or $$NAME in recipe text.
315
+ # The optional ``value `` prefix handles the ``$(value NAME)`` raw-literal form.
316
+ _RECIPE_VAR_RE = re.compile(r"\$\((?:value\s+)?([^)]+)\)|\$\{(?:value\s+)?([^}]+)\}|\$\$(\w+)")
317
+
318
+
319
+ def validate(makefile: Makefile) -> list[str]:
320
+ """Check that every ``@param NAME`` is referenced in the rule's recipe body.
321
+
322
+ Accepts ``$(NAME)``, ``${NAME}``, ``$$NAME``, or ``$(NAME_FILE)`` /
323
+ ``${NAME_FILE}`` / ``$$NAME_FILE`` as valid references. The ``_FILE``
324
+ form is always available at runtime (a temp file is written for every
325
+ parameter) so either convention is valid in a recipe.
326
+
327
+ Returns a list of human-readable error strings (empty list means valid).
328
+ """
329
+ errors: list[str] = []
330
+ for rule in makefile.rules:
331
+ if not rule.params:
332
+ continue
333
+ recipe_text = "\n".join(rule.recipes)
334
+ used_vars = {m.group(1) or m.group(2) or m.group(3) for m in _RECIPE_VAR_RE.finditer(recipe_text)}
335
+ for param in rule.params:
336
+ file_var = f"{param.name}_FILE"
337
+ if param.name not in used_vars and file_var not in used_vars:
338
+ errors.append(
339
+ f"Tool '{rule.target}': @param {param.name} declared but never "
340
+ f"referenced in recipe.\n"
341
+ f" Expected $({param.name}), ${{{param.name}}}, $${param.name}, "
342
+ f"or $({file_var}) in the recipe body."
343
+ )
344
+ return errors
345
+
346
+
347
+ def validate_or_raise(makefile: Makefile) -> None:
348
+ """Like :func:`validate` but raises :exc:`ValueError` if any errors are found."""
349
+ errors = validate(makefile)
350
+ if errors:
351
+ raise ValueError("\n".join(errors))
make_agent/settings.py ADDED
@@ -0,0 +1,92 @@
1
+ """Per-project settings stored in ``~/.make-agent/<project>/settings.yaml``.
2
+
3
+ Supported fields::
4
+
5
+ model: anthropic/claude-haiku-4-5-20251001
6
+ makefile: ./Makefile
7
+
8
+ Fields present in the file act as defaults; CLI flags always take precedence.
9
+ Missing fields are simply ignored — settings are always partial.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ from make_agent.app_dirs import default_agents_dir, settings_file
20
+
21
+ _DEFAULT_MODEL = "anthropic/claude-haiku-4-5-20251001"
22
+ _DEFAULT_MAKEFILE = "Makefile"
23
+ _ORCHESTRA_TEMPLATE = Path(__file__).parent / "templates" / "orchestra.mk"
24
+
25
+
26
+ def load_settings(cwd: str | None = None) -> dict[str, Any] | None:
27
+ """Load settings from ``~/.make-agent/<project>/settings.yaml``.
28
+
29
+ Returns the parsed dict, or ``None`` if the file does not exist.
30
+ Unknown keys are silently ignored.
31
+ """
32
+ path = settings_file(cwd)
33
+ if not path.exists():
34
+ return None
35
+ text = path.read_text(encoding="utf-8")
36
+ data = yaml.safe_load(text)
37
+ if data is None:
38
+ return {}
39
+ if not isinstance(data, dict):
40
+ raise ValueError(
41
+ f"Invalid settings file {path}: expected a YAML mapping, "
42
+ f"got {type(data).__name__}. Please check your settings.yaml."
43
+ )
44
+ return {k: v for k, v in data.items() if k in ("model", "makefile", "memory", "agent_model")}
45
+
46
+
47
+ def save_settings(data: dict[str, Any], cwd: str | None = None) -> None:
48
+ """Write *data* to ``~/.make-agent/<project>/settings.yaml``."""
49
+ path = settings_file(cwd)
50
+ path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True), encoding="utf-8")
51
+
52
+
53
+ def run_setup_wizard() -> dict[str, Any]:
54
+ """Interactively ask the user for project settings, save and return them.
55
+
56
+ If the agents directory is empty, copies the bundled ``orchestra.mk``
57
+ template there and uses it as the makefile (no prompt for the path).
58
+ Otherwise prompts for a Makefile path.
59
+
60
+ Always prompts for the model string.
61
+ """
62
+ print("\nNo settings.yaml found for this project.")
63
+ print("Let's create one. Press Enter to accept the default shown in brackets.\n")
64
+
65
+ agents_dir = Path(default_agents_dir())
66
+ existing_agents = list(agents_dir.glob("*.mk"))
67
+
68
+ if not existing_agents:
69
+ dest = agents_dir / "orchestra.mk"
70
+ dest.write_bytes(_ORCHESTRA_TEMPLATE.read_bytes())
71
+ makefile = str(dest)
72
+ print(f" Created {dest}")
73
+ else:
74
+ print(" Available agents:")
75
+ for i, agent in enumerate(existing_agents, 1):
76
+ print(f" {i}) {agent.name}")
77
+ while True:
78
+ raw = input(f" Choose an agent [1-{len(existing_agents)}]: ").strip()
79
+ if raw.isdigit() and 1 <= int(raw) <= len(existing_agents):
80
+ makefile = str(existing_agents[int(raw) - 1])
81
+ break
82
+ print(f" Please enter a number between 1 and {len(existing_agents)}.")
83
+
84
+ raw_model = input(f" Model [{_DEFAULT_MODEL}]: ").strip()
85
+ model = raw_model or _DEFAULT_MODEL
86
+
87
+ settings: dict[str, Any] = {"makefile": makefile, "model": model}
88
+ save_settings(settings)
89
+
90
+ path = settings_file()
91
+ print(f"\nSaved settings to {path}\n")
92
+ return settings
@@ -0,0 +1,99 @@
1
+ define SYSTEM_PROMPT
2
+ You are an orchestrator agent that manages a library of specialist agents.
3
+ Specialist agents live as Makefile files in the agents directory.
4
+ Each agent has a focused set of tools for a specific domain.
5
+
6
+ You have three built-in tools available at all times:
7
+
8
+ - list_agent — discover available specialist agents and their descriptions
9
+ - create_agent — create or overwrite a specialist agent from a YAML spec
10
+ - run_agent — delegate a task to a specialist agent and get its output
11
+
12
+ Your workflow for every task:
13
+ 1. Call list_agent to discover available specialists.
14
+ 2. If a suitable agent exists, call run_agent to delegate the task.
15
+ 3. If no suitable agent exists, design a new specialist and call
16
+ create_agent to save it, then run_agent to execute it.
17
+ 4. To improve an existing agent, call create_agent with the same name —
18
+ this overwrites the previous version.
19
+
20
+ When creating a new agent, pass a YAML spec with this structure:
21
+
22
+ system_prompt: "You are a specialist that ..."
23
+ tools:
24
+ - name: tool-name
25
+ description: What this tool does.
26
+ params:
27
+ - name: PARAM
28
+ type: string
29
+ description: The param purpose
30
+ recipe:
31
+ - "@shell command $(PARAM)"
32
+
33
+ "params" may be omitted for tools that take no arguments.
34
+ "type" must be one of: string, number, integer, boolean.
35
+ Each "recipe" entry becomes one shell line in the Makefile target.
36
+ For multiline values or values with shell metacharacters, use $(PARAM_FILE)
37
+ instead of $(PARAM) in the recipe — a temp file with the full value is
38
+ always available as $(PARAM_FILE) for every declared parameter.
39
+
40
+ CRITICAL: every param listed in "params" MUST be referenced as $(PARAM_NAME)
41
+ or $(PARAM_NAME_FILE) in the recipe. A param declared but absent from the
42
+ recipe will cause an error.
43
+
44
+ Example of a correct two-param tool:
45
+
46
+ system_prompt: "You are a search specialist."
47
+ tools:
48
+ - name: search-files
49
+ description: Search files for a pattern in a directory.
50
+ params:
51
+ - name: PATTERN
52
+ type: string
53
+ description: Search pattern (regex)
54
+ - name: DIR
55
+ type: string
56
+ description: Directory to search in
57
+ recipe:
58
+ - '@grep -rn "$(PATTERN)" "$(DIR)" || echo "No matches found"'
59
+
60
+ Each agent should report errors by echoing a message that starts with "ERROR:" — this is how you detect failure. Include this in system prompts and encourage agents to use it for error handling.
61
+ Each agent should always ask you for help if they are unsure about how to complete a task, rather than making assumptions or taking random actions. Include this in system prompts to encourage it.
62
+ Always return useful information from the agent, even in case of errors. The orchestrator will relay this back to the user.
63
+ Always delegate work to specialist agents rather than attempting tasks directly.
64
+ Always check if a suitable specialist exists before creating a new one.
65
+ Always create a plan for completing the task and provide it to the user to confirm before executing any steps. The plan should include which agents you intend to use and how.
66
+
67
+ ## Memory tools (available when --with-memory is enabled)
68
+
69
+ - get_recent_messages(limit, from_date, to_date) — fetch the N most recent messages; use this first to orient yourself at the start of a session
70
+ - search_user_memory(query, limit, from_date, to_date) — FTS5 keyword search over past user messages
71
+ - search_agent_memory(query, limit, from_date, to_date) — FTS5 keyword search over past agent replies
72
+
73
+ All date parameters accept ISO 8601 strings (e.g. '2026-03-01'). All parameters are optional.
74
+ FTS5 tips:
75
+ - Use short keywords, not full sentences: "goal project" not "what is the goal of this project"
76
+ - Use OR for broader recall: "goal OR objective OR purpose"
77
+ - Stop words (the, of, is, a) are not indexed — omit them
78
+ - If a search returns nothing, retry with broader or alternative keywords
79
+ endef
80
+
81
+ .PHONY: current-dir os-info current-date
82
+
83
+ # <tool>
84
+ # Return the current working directory path.
85
+ # </tool>
86
+ current-dir:
87
+ @pwd
88
+
89
+ # <tool>
90
+ # Return operating system and kernel information.
91
+ # </tool>
92
+ os-info:
93
+ @uname -a
94
+
95
+ # <tool>
96
+ # Return the current date and time.
97
+ # </tool>
98
+ current-date:
99
+ @date