aes-cli 0.2.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.
Files changed (48) hide show
  1. aes/__init__.py +5 -0
  2. aes/__main__.py +37 -0
  3. aes/analyzer.py +487 -0
  4. aes/commands/__init__.py +0 -0
  5. aes/commands/init.py +727 -0
  6. aes/commands/inspect.py +204 -0
  7. aes/commands/install.py +379 -0
  8. aes/commands/publish.py +432 -0
  9. aes/commands/search.py +65 -0
  10. aes/commands/status.py +153 -0
  11. aes/commands/sync.py +413 -0
  12. aes/commands/validate.py +77 -0
  13. aes/config.py +43 -0
  14. aes/domains.py +1382 -0
  15. aes/frameworks.py +522 -0
  16. aes/mcp_server.py +213 -0
  17. aes/registry.py +294 -0
  18. aes/scaffold/agent.yaml.jinja +135 -0
  19. aes/scaffold/agentignore.jinja +61 -0
  20. aes/scaffold/instructions.md.jinja +311 -0
  21. aes/scaffold/local.example.yaml.jinja +35 -0
  22. aes/scaffold/local.yaml.jinja +29 -0
  23. aes/scaffold/operations.md.jinja +33 -0
  24. aes/scaffold/orchestrator.md.jinja +95 -0
  25. aes/scaffold/permissions.yaml.jinja +151 -0
  26. aes/scaffold/setup.md.jinja +244 -0
  27. aes/scaffold/skill.md.jinja +27 -0
  28. aes/scaffold/skill.yaml.jinja +175 -0
  29. aes/scaffold/workflow.yaml.jinja +44 -0
  30. aes/scaffold/workflow_command.md.jinja +48 -0
  31. aes/schemas/agent.schema.json +188 -0
  32. aes/schemas/permissions.schema.json +100 -0
  33. aes/schemas/registry.schema.json +72 -0
  34. aes/schemas/skill.schema.json +209 -0
  35. aes/schemas/workflow.schema.json +92 -0
  36. aes/targets/__init__.py +29 -0
  37. aes/targets/_base.py +77 -0
  38. aes/targets/_composer.py +338 -0
  39. aes/targets/claude.py +153 -0
  40. aes/targets/copilot.py +48 -0
  41. aes/targets/cursor.py +46 -0
  42. aes/targets/windsurf.py +46 -0
  43. aes/validator.py +394 -0
  44. aes_cli-0.2.0.dist-info/METADATA +110 -0
  45. aes_cli-0.2.0.dist-info/RECORD +48 -0
  46. aes_cli-0.2.0.dist-info/WHEEL +5 -0
  47. aes_cli-0.2.0.dist-info/entry_points.txt +3 -0
  48. aes_cli-0.2.0.dist-info/top_level.txt +1 -0
aes/commands/init.py ADDED
@@ -0,0 +1,727 @@
1
+ """aes init — Scaffold a .agent/ directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+
14
+ import click
15
+ from jinja2 import Environment, FileSystemLoader
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.tree import Tree
19
+
20
+ from aes.config import (
21
+ AGENT_DIR,
22
+ AGENTIGNORE_FILE,
23
+ LOCAL_EXAMPLE_FILE,
24
+ LOCAL_FILE,
25
+ SCAFFOLD_DIR,
26
+ SKILLS_DIR,
27
+ REGISTRY_DIR,
28
+ WORKFLOWS_DIR,
29
+ COMMANDS_DIR,
30
+ MEMORY_DIR,
31
+ OVERRIDES_DIR,
32
+ )
33
+ from aes.commands.install import _safe_extract
34
+ from aes.commands.sync import run_sync
35
+ from aes.domains import AGENT_INTEGRATED_BASE_CONFIG, DEV_ASSIST_BASE_CONFIG, DOMAIN_CONFIGS
36
+ from aes.analyzer import analyze_project, ProjectAnalysis
37
+ from aes.frameworks import resolve_config
38
+
39
+ console = Console()
40
+
41
+ MCP_CONFIG_FILE = ".mcp.json"
42
+
43
+ _MCP_CONFIG = {
44
+ "mcpServers": {
45
+ "aes-registry": {
46
+ "command": "aes-mcp",
47
+ "args": [],
48
+ }
49
+ }
50
+ }
51
+
52
+
53
+ def _write_mcp_config(project_root: Path) -> bool:
54
+ """Write .mcp.json if it doesn't already exist. Returns True if written."""
55
+ mcp_path = project_root / MCP_CONFIG_FILE
56
+ if mcp_path.exists():
57
+ return False
58
+ mcp_path.write_text(json.dumps(_MCP_CONFIG, indent=2) + "\n")
59
+ return True
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Auto-detection helpers (kept for backward compat with explicit --language)
64
+ # ---------------------------------------------------------------------------
65
+
66
+ _LANGUAGE_MARKERS = [
67
+ ("python", ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]),
68
+ ("typescript", ["tsconfig.json"]),
69
+ ("javascript", ["package.json"]),
70
+ ("go", ["go.mod"]),
71
+ ("rust", ["Cargo.toml"]),
72
+ ("java", ["pom.xml", "build.gradle", "build.gradle.kts"]),
73
+ ]
74
+
75
+
76
+ def _detect_language(project_root: Path) -> str:
77
+ """Auto-detect the primary language from marker files in *project_root*."""
78
+ for language, markers in _LANGUAGE_MARKERS:
79
+ for marker in markers:
80
+ if (project_root / marker).exists():
81
+ return language
82
+ return "other"
83
+
84
+
85
+ def _detect_name(project_root: Path) -> str:
86
+ """Derive a kebab-case project name from the directory name."""
87
+ raw = project_root.name
88
+ kebab = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")
89
+ return kebab or "my-project"
90
+
91
+
92
+ def _render_template(env: Environment, template_name: str, context: dict) -> str:
93
+ """Render a Jinja2 template with context."""
94
+ tmpl = env.get_template(template_name)
95
+ return tmpl.render(**context)
96
+
97
+
98
+ def _init_from_registry(source: str, project_root: Path) -> None:
99
+ """Initialize a project from a registry template.
100
+
101
+ *source* is a registry reference like ``aes-hub/ml-pipeline@^2.0``.
102
+ Downloads the template tarball and extracts its ``.agent/`` directory.
103
+ """
104
+ from aes.registry import (
105
+ parse_registry_source,
106
+ fetch_index,
107
+ resolve_version,
108
+ download_package,
109
+ )
110
+
111
+ name, version_spec = parse_registry_source(source)
112
+
113
+ # Fetch index and resolve version
114
+ try:
115
+ index = fetch_index()
116
+ except Exception as exc:
117
+ raise click.ClickException(f"Failed to fetch registry: {exc}")
118
+
119
+ pkg = index.get("packages", {}).get(name)
120
+ if not pkg:
121
+ raise click.ClickException(f"Package '{name}' not found in registry")
122
+
123
+ available = list(pkg.get("versions", {}).keys())
124
+ version = resolve_version(version_spec, available)
125
+ if not version:
126
+ raise click.ClickException(
127
+ f"No version of '{name}' matching '{version_spec}'. "
128
+ f"Available: {', '.join(available)}"
129
+ )
130
+
131
+ ver_info = pkg["versions"][version]
132
+
133
+ # Check for existing .agent/ directory
134
+ agent_dir = project_root / AGENT_DIR
135
+ if agent_dir.exists():
136
+ console.print(f"[yellow]Warning:[/] {AGENT_DIR}/ already exists at {project_root}")
137
+ if not click.confirm("Overwrite existing files?", default=False):
138
+ raise SystemExit(1)
139
+
140
+ # Download and extract
141
+ with tempfile.TemporaryDirectory() as tmp:
142
+ tmp_path = Path(tmp)
143
+ tarball = download_package(
144
+ name, version, ver_info["sha256"], tmp_path,
145
+ )
146
+
147
+ with tarfile.open(tarball, "r:gz") as tar:
148
+ _safe_extract(tar, tmp_path)
149
+
150
+ # Find .agent/ directory inside extracted content
151
+ extracted_agent = None
152
+ for candidate in tmp_path.rglob(AGENT_DIR):
153
+ if candidate.is_dir() and (candidate / "agent.yaml").exists():
154
+ extracted_agent = candidate
155
+ break
156
+
157
+ if extracted_agent is None:
158
+ raise click.ClickException(
159
+ f"Downloaded package '{name}@{version}' does not contain "
160
+ f"a {AGENT_DIR}/ directory with agent.yaml"
161
+ )
162
+
163
+ # Copy to project root
164
+ if agent_dir.exists():
165
+ shutil.rmtree(agent_dir)
166
+ shutil.copytree(extracted_agent, agent_dir, symlinks=False)
167
+
168
+ # Auto-sync
169
+ synced_files = run_sync(project_root, force=True, quiet=True)
170
+ mcp_written = _write_mcp_config(project_root)
171
+
172
+ console.print()
173
+ console.print(f"[green]Initialized from template:[/] {name}@{version}")
174
+ console.print(f" Source: {source}")
175
+ console.print(f" Installed to: {agent_dir}")
176
+ if synced_files > 0:
177
+ console.print(f" Synced to {synced_files} tool-specific config file(s).")
178
+ if mcp_written:
179
+ console.print(f" Created {MCP_CONFIG_FILE} (AES registry MCP server)")
180
+ console.print()
181
+ console.print("[dim]Done! Start a new agent session to use the template.[/]")
182
+
183
+
184
+ def _init_from_tarball(tarball_path: Path, project_root: Path) -> None:
185
+ """Initialize a project from a local template tarball."""
186
+ agent_dir = project_root / AGENT_DIR
187
+
188
+ if agent_dir.exists():
189
+ console.print(f"[yellow]Warning:[/] {AGENT_DIR}/ already exists at {project_root}")
190
+ if not click.confirm("Overwrite existing files?", default=False):
191
+ raise SystemExit(1)
192
+
193
+ with tempfile.TemporaryDirectory() as tmp:
194
+ tmp_path = Path(tmp)
195
+ with tarfile.open(tarball_path, "r:gz") as tar:
196
+ _safe_extract(tar, tmp_path)
197
+
198
+ # Find .agent/ directory inside extracted content
199
+ extracted_agent = None
200
+ for candidate in tmp_path.rglob(AGENT_DIR):
201
+ if candidate.is_dir() and (candidate / "agent.yaml").exists():
202
+ extracted_agent = candidate
203
+ break
204
+
205
+ if extracted_agent is None:
206
+ raise click.ClickException(
207
+ f"Tarball does not contain a {AGENT_DIR}/ directory with agent.yaml"
208
+ )
209
+
210
+ if agent_dir.exists():
211
+ shutil.rmtree(agent_dir)
212
+ shutil.copytree(extracted_agent, agent_dir, symlinks=False)
213
+
214
+ synced_files = run_sync(project_root, force=True, quiet=True)
215
+ mcp_written = _write_mcp_config(project_root)
216
+
217
+ console.print()
218
+ console.print(f"[green]Initialized from template:[/] {tarball_path.name}")
219
+ console.print(f" Installed to: {agent_dir}")
220
+ if synced_files > 0:
221
+ console.print(f" Synced to {synced_files} tool-specific config file(s).")
222
+ if mcp_written:
223
+ console.print(f" Created {MCP_CONFIG_FILE} (AES registry MCP server)")
224
+ console.print()
225
+ console.print("[dim]Done! Start a new agent session to use the template.[/]")
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Interactive picker (when nothing detected)
230
+ # ---------------------------------------------------------------------------
231
+
232
+ _MODE_CHOICES = [
233
+ ("Dev-Assist — Agent builds the project, then steps back", "dev-assist"),
234
+ ("Agent-Integrated — Agent is embedded in the running product", "agent-integrated"),
235
+ ]
236
+
237
+ _DEV_ASSIST_TYPES = [
238
+ ("API service", "api"),
239
+ ("Web app", "fullstack"),
240
+ ("CLI tool", "cli-tool"),
241
+ ("Library / Package", "library"),
242
+ ("DevOps / Infra", "devops"),
243
+ ("Skip", "other"),
244
+ ]
245
+
246
+ _AGENT_INTEGRATED_TYPES = [
247
+ ("ML pipeline", "ml"),
248
+ ("Research / Content pipeline", "research"),
249
+ ("Custom", "other"),
250
+ ]
251
+
252
+ _LANGUAGE_CHOICES = ["python", "typescript", "javascript", "go", "rust", "java"]
253
+
254
+ # project_type + language -> list of framework labels (from FRAMEWORK_OVERLAYS keys)
255
+ _FRAMEWORK_PICKER: Dict[tuple, List[str]] = {
256
+ ("api", "python"): ["fastapi", "django", "flask"],
257
+ ("api", "typescript"): ["express"],
258
+ ("api", "javascript"): ["express"],
259
+ ("api", "go"): [], # gin/fiber/echo not in overlays yet
260
+ ("api", "rust"): [], # actix/rocket/axum not in overlays yet
261
+ ("fullstack", "typescript"): ["nextjs", "react"],
262
+ ("fullstack", "javascript"): ["nextjs", "react"],
263
+ ("web-frontend", "typescript"): ["nextjs", "react"],
264
+ ("web-frontend", "javascript"): ["nextjs", "react"],
265
+ }
266
+
267
+
268
+ def _interactive_pick(analysis: ProjectAnalysis) -> tuple:
269
+ """Show a two-step interactive picker when nothing was auto-detected.
270
+
271
+ Step 1: Choose mode (dev-assist vs agent-integrated).
272
+ Step 2: Choose project type within the selected mode.
273
+
274
+ Returns ``(project_type, language, frameworks, mode)`` chosen by the user.
275
+ """
276
+ console.print()
277
+ console.print("[bold]How will the agent work with this project?[/]\n")
278
+
279
+ # --- Step 1: Mode ---
280
+ for i, (label, _) in enumerate(_MODE_CHOICES, 1):
281
+ console.print(f" [bold cyan][{i}][/] {label}")
282
+ console.print()
283
+
284
+ mode_idx = click.prompt(
285
+ "Choice",
286
+ type=click.IntRange(1, len(_MODE_CHOICES)),
287
+ default=1,
288
+ )
289
+ _, chosen_mode = _MODE_CHOICES[mode_idx - 1]
290
+
291
+ # --- Step 2: Project type based on mode ---
292
+ type_choices = _DEV_ASSIST_TYPES if chosen_mode == "dev-assist" else _AGENT_INTEGRATED_TYPES
293
+
294
+ console.print()
295
+ console.print("[bold]What type of project?[/]\n")
296
+ for i, (label, _) in enumerate(type_choices, 1):
297
+ console.print(f" [bold cyan][{i}][/] {label}")
298
+ console.print()
299
+
300
+ type_idx = click.prompt(
301
+ "Choice",
302
+ type=click.IntRange(1, len(type_choices)),
303
+ default=len(type_choices),
304
+ )
305
+ chosen_label, chosen_type = type_choices[type_idx - 1]
306
+
307
+ if chosen_type == "other":
308
+ return ("other", analysis.language, [], chosen_mode)
309
+
310
+ # Domain configs (ml, web, devops, research) skip language/framework
311
+ if chosen_type in DOMAIN_CONFIGS:
312
+ lang = analysis.language if analysis.language != "other" else "python"
313
+ return (chosen_type, lang, [], chosen_mode)
314
+
315
+ # --- Language ---
316
+ console.print()
317
+ console.print("[bold]Language?[/]\n")
318
+ for i, lang in enumerate(_LANGUAGE_CHOICES, 1):
319
+ console.print(f" [bold cyan][{i}][/] {lang.title()}")
320
+ console.print()
321
+
322
+ lang_idx = click.prompt(
323
+ "Choice",
324
+ type=click.IntRange(1, len(_LANGUAGE_CHOICES)),
325
+ default=1,
326
+ )
327
+ chosen_lang = _LANGUAGE_CHOICES[lang_idx - 1]
328
+
329
+ # --- Framework (optional) ---
330
+ fw_options = _FRAMEWORK_PICKER.get((chosen_type, chosen_lang), [])
331
+ chosen_frameworks: List[str] = []
332
+
333
+ if fw_options:
334
+ console.print()
335
+ console.print("[bold]Framework?[/] (optional, Enter to skip)\n")
336
+ for i, fw in enumerate(fw_options, 1):
337
+ console.print(f" [bold cyan][{i}][/] {fw.title()}")
338
+ console.print(f" [bold cyan][{len(fw_options) + 1}][/] None")
339
+ console.print()
340
+
341
+ fw_idx = click.prompt(
342
+ "Choice",
343
+ type=click.IntRange(1, len(fw_options) + 1),
344
+ default=len(fw_options) + 1,
345
+ )
346
+ if fw_idx <= len(fw_options):
347
+ chosen_frameworks = [fw_options[fw_idx - 1]]
348
+
349
+ return (chosen_type, chosen_lang, chosen_frameworks, chosen_mode)
350
+
351
+
352
+ def _format_detection_summary(analysis: ProjectAnalysis) -> str:
353
+ """Build a detection summary string for display."""
354
+ lines = []
355
+ lines.append(f" Language: [cyan]{analysis.language}[/]")
356
+ if analysis.frameworks:
357
+ fw_str = " + ".join(analysis.frameworks)
358
+ lines.append(f" Framework: [cyan]{fw_str}[/]")
359
+ lines.append(f" Type: [cyan]{analysis.project_type}[/]")
360
+ if analysis.has_tests:
361
+ cmd_hint = f" ({analysis.test_command})" if analysis.test_command else ""
362
+ lines.append(f" Tests: [green]found[/]{cmd_hint}")
363
+ if analysis.has_ci:
364
+ lines.append(f" CI/CD: [green]found[/]")
365
+ if analysis.has_docker:
366
+ lines.append(f" Docker: [green]found[/]")
367
+ if analysis.has_database:
368
+ lines.append(f" Database: [green]migrations found[/]")
369
+ if analysis.existing_agent_configs:
370
+ tools = ", ".join(analysis.existing_agent_configs.keys())
371
+ lines.append(f" Existing: [yellow]{tools}[/]")
372
+ return "\n".join(lines)
373
+
374
+
375
+ def _print_post_init_summary(
376
+ project_root: Path,
377
+ name: str,
378
+ project_type: str,
379
+ language: str,
380
+ domain_config: object,
381
+ skills: bool,
382
+ workflows: bool,
383
+ registry: bool,
384
+ synced_files: int,
385
+ ) -> None:
386
+ """Print a rich post-init summary."""
387
+ from aes.domains import DomainConfig
388
+
389
+ # Header
390
+ type_label = project_type.replace("-", " ").title()
391
+ console.print()
392
+ console.print(Panel(
393
+ f"[bold green]AES Initialized:[/] {name}\n"
394
+ f"[dim]{type_label} ({language})[/]",
395
+ expand=False,
396
+ ))
397
+
398
+ # File tree
399
+ tree = Tree(f"[bold].agent/[/]")
400
+ tree.add("agent.yaml")
401
+ tree.add(f"instructions.md [dim]({type_label}-specific)[/]")
402
+ tree.add("permissions.yaml")
403
+
404
+ if skills:
405
+ skills_branch = tree.add("skills/")
406
+ skills_branch.add("ORCHESTRATOR.md")
407
+ if isinstance(domain_config, DomainConfig):
408
+ for skill_def in domain_config.skills:
409
+ skills_branch.add(f"{skill_def.id} [dim]{skill_def.description}[/]")
410
+
411
+ if workflows and isinstance(domain_config, DomainConfig) and domain_config.workflow:
412
+ wf_branch = tree.add("workflows/")
413
+ wf_branch.add(f"{domain_config.workflow.id}.yaml")
414
+
415
+ if registry:
416
+ tree.add("registry/")
417
+
418
+ cmd_branch = tree.add("commands/")
419
+ cmd_branch.add("setup.md")
420
+ if isinstance(domain_config, DomainConfig) and domain_config.workflow_commands:
421
+ for cmd_def in domain_config.workflow_commands:
422
+ cmd_branch.add(f"{cmd_def.id}.md [dim]{cmd_def.trigger}[/]")
423
+
424
+ mem_branch = tree.add("memory/")
425
+ mem_branch.add("project.md")
426
+ if isinstance(domain_config, DomainConfig) and domain_config.workflow_commands:
427
+ mem_branch.add("operations.md [dim](per-command activity log)[/]")
428
+
429
+ console.print(tree)
430
+
431
+ # Sync summary
432
+ if synced_files > 0:
433
+ console.print()
434
+ console.print(f"[green]Synced to {synced_files} tool config(s):[/]")
435
+ sync_targets = [
436
+ ("Claude Code", "CLAUDE.md"),
437
+ ("Cursor", ".cursorrules"),
438
+ ("Copilot", ".github/copilot-instructions.md"),
439
+ ("Windsurf", ".windsurfrules"),
440
+ ]
441
+ for tool_name, file_name in sync_targets:
442
+ if (project_root / file_name).exists():
443
+ console.print(f" {tool_name:12s} -> {file_name}")
444
+
445
+ # MCP info
446
+ mcp_path = project_root / MCP_CONFIG_FILE
447
+ if mcp_path.exists():
448
+ console.print()
449
+ console.print(f"[green]MCP:[/] {MCP_CONFIG_FILE} configured (AES registry tools)")
450
+ console.print(" [dim]Install MCP server: pip install aes-cli[mcp][/]")
451
+
452
+ # Next steps
453
+ console.print()
454
+ workflow_hint = ""
455
+ if isinstance(domain_config, DomainConfig) and domain_config.workflow_commands:
456
+ trigger = domain_config.workflow_commands[0].trigger
457
+ workflow_hint = f", or {trigger} to begin"
458
+ console.print(f"[dim]Next: Start a new agent session, then type /setup to fine-tune{workflow_hint}.[/]")
459
+
460
+
461
+ @click.command("init")
462
+ @click.option("--name", default=None, help="Project name (kebab-case). Default: directory name.")
463
+ @click.option("--domain", default=None, help="Project domain (ml, web, devops, etc.). Default: auto-detected.")
464
+ @click.option("--language", default=None, help="Primary language. Default: auto-detected from files.")
465
+ @click.option("--skills/--no-skills", default=True)
466
+ @click.option("--workflows/--no-workflows", default=True)
467
+ @click.option("--registry/--no-registry", default=False)
468
+ @click.option("--path", default=".", type=click.Path(exists=True), help="Project root directory")
469
+ @click.option("--from", "from_registry", default=None, help="Initialize from a registry template (e.g. aes-hub/ml-pipeline@^2.0) or local tarball")
470
+ def init_cmd(
471
+ name: Optional[str],
472
+ domain: Optional[str],
473
+ language: Optional[str],
474
+ skills: bool,
475
+ workflows: bool,
476
+ registry: bool,
477
+ path: str,
478
+ from_registry: Optional[str],
479
+ ) -> None:
480
+ """Scaffold a .agent/ directory for your project.
481
+
482
+ All flags are optional. Without flags, the project is analyzed to detect
483
+ language, frameworks, and project type. The generated .agent/ is tailored
484
+ to the detected stack.
485
+
486
+ Use --domain ml/web/devops to use a pre-built domain config instead of
487
+ auto-detection. Use --from for registry templates or local tarballs.
488
+
489
+ \b
490
+ Examples:
491
+ aes init # zero-arg, auto-detect everything
492
+ aes init --name my-app --domain ml # explicit name and domain
493
+ aes init --language python --no-workflows # override auto-detect
494
+ aes init --from aes-hub/ml-pipeline@^2.0 # from registry template
495
+ aes init --from ./template.tar.gz # from local tarball
496
+ """
497
+ project_root = Path(path).resolve()
498
+
499
+ # --from: initialize from registry template or local tarball
500
+ if from_registry:
501
+ source_path = Path(from_registry)
502
+ if source_path.exists() and source_path.suffix == ".gz":
503
+ _init_from_tarball(source_path, project_root)
504
+ else:
505
+ _init_from_registry(from_registry, project_root)
506
+ return
507
+
508
+ agent_dir = project_root / AGENT_DIR
509
+
510
+ # --- Smart detection mode ---
511
+ # When no --domain is given, analyze the project
512
+ analysis: Optional[ProjectAnalysis] = None
513
+ detected_domain_config = None
514
+
515
+ if domain is None:
516
+ analysis = analyze_project(project_root)
517
+
518
+ # Use analysis for defaults
519
+ if name is None:
520
+ name = analysis.name
521
+ if language is None:
522
+ language = analysis.language
523
+
524
+ # Try to resolve a framework-aware config
525
+ detected_domain_config = resolve_config(
526
+ project_type=analysis.project_type,
527
+ frameworks=analysis.frameworks,
528
+ language=language,
529
+ test_command=analysis.test_command,
530
+ build_command=analysis.build_command,
531
+ )
532
+
533
+ # Interactive mode: show what we found and confirm
534
+ is_interactive = sys.stdin.isatty() and not any(
535
+ x in sys.argv for x in ("--name", "--language")
536
+ )
537
+ if is_interactive and (analysis.frameworks or analysis.project_type != "other"):
538
+ console.print()
539
+ console.print(Panel(
540
+ f"[bold]Detected:[/]\n{_format_detection_summary(analysis)}",
541
+ title="Project Analysis",
542
+ expand=False,
543
+ ))
544
+
545
+ type_label = analysis.project_type.replace("-", " ").title()
546
+ fw_str = ""
547
+ if analysis.frameworks:
548
+ fw_str = " + ".join(f.title() for f in analysis.frameworks) + " "
549
+ if not click.confirm(
550
+ f"\nGenerate .agent/ for {fw_str}{type_label}?",
551
+ default=True,
552
+ ):
553
+ raise SystemExit(0)
554
+
555
+ # Offer to import existing agent configs
556
+ if analysis.existing_agent_configs:
557
+ console.print()
558
+ for tool, cfg_path in analysis.existing_agent_configs.items():
559
+ size = cfg_path.stat().st_size
560
+ console.print(f" Found existing: [yellow]{cfg_path.name}[/] ({size / 1024:.1f} KB)")
561
+
562
+ elif is_interactive:
563
+ # Nothing detected — show interactive picker
564
+ picked_type, picked_lang, picked_frameworks, picked_mode = _interactive_pick(analysis)
565
+
566
+ language = picked_lang
567
+ if picked_type in DOMAIN_CONFIGS:
568
+ detected_domain_config = DOMAIN_CONFIGS.get(picked_type)
569
+ elif picked_type != "other":
570
+ detected_domain_config = resolve_config(
571
+ project_type=picked_type,
572
+ frameworks=picked_frameworks,
573
+ language=picked_lang,
574
+ )
575
+ elif picked_mode == "agent-integrated":
576
+ detected_domain_config = AGENT_INTEGRATED_BASE_CONFIG
577
+ else:
578
+ detected_domain_config = DEV_ASSIST_BASE_CONFIG
579
+ # Update analysis so post-init summary is correct
580
+ analysis = ProjectAnalysis(
581
+ name=analysis.name,
582
+ language=picked_lang,
583
+ frameworks=picked_frameworks,
584
+ project_type=picked_type,
585
+ )
586
+
587
+ # Fall into "other" domain handling for the template context
588
+ domain = analysis.project_type if detected_domain_config else "other"
589
+ else:
590
+ # Explicit --domain: use legacy behavior
591
+ if name is None:
592
+ name = _detect_name(project_root)
593
+ if language is None:
594
+ language = _detect_language(project_root)
595
+
596
+ if agent_dir.exists():
597
+ console.print(f"[yellow]Warning:[/] {AGENT_DIR}/ already exists at {project_root}")
598
+ if not click.confirm("Overwrite existing files?", default=False):
599
+ raise SystemExit(1)
600
+
601
+ # Look up domain config: framework-resolved > explicit domain > None
602
+ domain_config = detected_domain_config or DOMAIN_CONFIGS.get(domain) or DEV_ASSIST_BASE_CONFIG
603
+
604
+ # Create directory structure
605
+ agent_dir.mkdir(exist_ok=True)
606
+ (agent_dir / MEMORY_DIR).mkdir(exist_ok=True)
607
+ (agent_dir / MEMORY_DIR / "sessions").mkdir(exist_ok=True)
608
+ (agent_dir / OVERRIDES_DIR).mkdir(exist_ok=True)
609
+
610
+ if skills:
611
+ (agent_dir / SKILLS_DIR).mkdir(exist_ok=True)
612
+ if workflows:
613
+ (agent_dir / WORKFLOWS_DIR).mkdir(exist_ok=True)
614
+ if registry:
615
+ (agent_dir / REGISTRY_DIR).mkdir(exist_ok=True)
616
+
617
+ context = {
618
+ "name": name,
619
+ "domain": domain,
620
+ "language": language,
621
+ "has_skills": skills,
622
+ "has_workflows": workflows,
623
+ "has_registry": registry,
624
+ "domain_config": domain_config,
625
+ }
626
+
627
+ # Render templates
628
+ env = Environment(
629
+ loader=FileSystemLoader(str(SCAFFOLD_DIR)),
630
+ keep_trailing_newline=True,
631
+ )
632
+
633
+ # agent.yaml
634
+ content = _render_template(env, "agent.yaml.jinja", context)
635
+ (agent_dir / "agent.yaml").write_text(content)
636
+
637
+ # instructions.md
638
+ content = _render_template(env, "instructions.md.jinja", context)
639
+ (agent_dir / "instructions.md").write_text(content)
640
+
641
+ # permissions.yaml
642
+ content = _render_template(env, "permissions.yaml.jinja", context)
643
+ (agent_dir / "permissions.yaml").write_text(content)
644
+
645
+ # .agentignore
646
+ agentignore_path = project_root / AGENTIGNORE_FILE
647
+ if not agentignore_path.exists():
648
+ content = _render_template(env, "agentignore.jinja", context)
649
+ agentignore_path.write_text(content)
650
+
651
+ # ORCHESTRATOR.md (if skills enabled)
652
+ if skills:
653
+ content = _render_template(env, "orchestrator.md.jinja", context)
654
+ (agent_dir / SKILLS_DIR / "ORCHESTRATOR.md").write_text(content)
655
+
656
+ # Domain-specific skill files (manifest + runbook)
657
+ if skills and domain_config:
658
+ for skill_def in domain_config.skills:
659
+ skill_context = {"skill": skill_def}
660
+ # Skill manifest
661
+ content = _render_template(env, "skill.yaml.jinja", skill_context)
662
+ (agent_dir / SKILLS_DIR / f"{skill_def.id}.skill.yaml").write_text(content)
663
+ # Skill runbook
664
+ content = _render_template(env, "skill.md.jinja", skill_context)
665
+ (agent_dir / SKILLS_DIR / f"{skill_def.id}.md").write_text(content)
666
+
667
+ # Domain-specific workflow file
668
+ if workflows and domain_config and domain_config.workflow:
669
+ workflow_context = {"workflow": domain_config.workflow}
670
+ content = _render_template(env, "workflow.yaml.jinja", workflow_context)
671
+ (agent_dir / WORKFLOWS_DIR / f"{domain_config.workflow.id}.yaml").write_text(content)
672
+
673
+ # Local config files
674
+ content = _render_template(env, "local.yaml.jinja", context)
675
+ (agent_dir / LOCAL_FILE).write_text(content)
676
+
677
+ content = _render_template(env, "local.example.yaml.jinja", context)
678
+ (agent_dir / LOCAL_EXAMPLE_FILE).write_text(content)
679
+
680
+ # Memory project.md
681
+ memory_content = f"# {name} — Agent Memory\n\n## Project Overview\n\n## Architecture\n\n## Status\n\n## Key Patterns\n"
682
+ (agent_dir / MEMORY_DIR / "project.md").write_text(memory_content)
683
+
684
+ # Commands directory + /setup runbook
685
+ (agent_dir / COMMANDS_DIR).mkdir(exist_ok=True)
686
+ content = _render_template(env, "setup.md.jinja", context)
687
+ (agent_dir / COMMANDS_DIR / "setup.md").write_text(content)
688
+
689
+ # Workflow command runbooks
690
+ if domain_config and domain_config.workflow_commands:
691
+ for cmd_def in domain_config.workflow_commands:
692
+ cmd_context = {"cmd": cmd_def}
693
+ content = _render_template(env, "workflow_command.md.jinja", cmd_context)
694
+ (agent_dir / COMMANDS_DIR / f"{cmd_def.id}.md").write_text(content)
695
+
696
+ # Operations memory file (when domain has workflow commands)
697
+ if domain_config and domain_config.workflow_commands:
698
+ ops_context = {
699
+ "name": name,
700
+ "domain_config": domain_config,
701
+ "workflow_commands": domain_config.workflow_commands,
702
+ }
703
+ content = _render_template(env, "operations.md.jinja", ops_context)
704
+ (agent_dir / MEMORY_DIR / "operations.md").write_text(content)
705
+
706
+ # Auto-sync: generate tool-specific config files
707
+ synced_files = run_sync(project_root, force=True, quiet=True)
708
+ _write_mcp_config(project_root)
709
+
710
+ # Determine project type label for output
711
+ project_type = "other"
712
+ if analysis is not None:
713
+ project_type = analysis.project_type
714
+ elif domain in ("ml", "web", "devops", "research"):
715
+ project_type = domain
716
+
717
+ _print_post_init_summary(
718
+ project_root=project_root,
719
+ name=name,
720
+ project_type=project_type,
721
+ language=language,
722
+ domain_config=domain_config,
723
+ skills=skills,
724
+ workflows=workflows,
725
+ registry=registry,
726
+ synced_files=synced_files,
727
+ )