agentfactory-gen 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.
- agent_gen/__init__.py +1 -0
- agent_gen/__main__.py +4 -0
- agent_gen/cli.py +689 -0
- agent_gen/librarian.py +981 -0
- agentfactory_gen-0.3.0.dist-info/METADATA +11 -0
- agentfactory_gen-0.3.0.dist-info/RECORD +9 -0
- agentfactory_gen-0.3.0.dist-info/WHEEL +5 -0
- agentfactory_gen-0.3.0.dist-info/entry_points.txt +2 -0
- agentfactory_gen-0.3.0.dist-info/top_level.txt +1 -0
agent_gen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AgentFactory — agent_gen package."""
|
agent_gen/__main__.py
ADDED
agent_gen/cli.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""agent-gen CLI — deploy, wrap, import."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from .librarian import TRACKED_DIRS, HARNESS_ROOT, CONTEXT_FILE, Librarian
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Helpers
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
def _agent_root(name: str) -> Path:
|
|
20
|
+
new = Path.cwd() / HARNESS_ROOT / "agents" / name
|
|
21
|
+
legacy = Path.cwd() / "agents" / name
|
|
22
|
+
# Backward-compat: fall back to legacy path if .ai/agents/ doesn't exist yet
|
|
23
|
+
if not new.parent.exists() and legacy.parent.exists():
|
|
24
|
+
return legacy
|
|
25
|
+
return new
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _clone_and_prepare(url: str) -> tuple[str, object]:
|
|
29
|
+
"""
|
|
30
|
+
Clone a git URL, retrofit it to AgentFactory standard, and wrap it as a ZIP.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
(zip_path_str, cleanup_fn) — caller must call cleanup_fn() after import.
|
|
34
|
+
"""
|
|
35
|
+
# Derive agent name from URL
|
|
36
|
+
name = url.rstrip("/").split("/")[-1]
|
|
37
|
+
if name.endswith(".git"):
|
|
38
|
+
name = name[:-4]
|
|
39
|
+
|
|
40
|
+
tmp_dir = tempfile.mkdtemp(prefix="agentfactory_import_")
|
|
41
|
+
cloned_dir = os.path.join(tmp_dir, name)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Validate URL scheme before passing to subprocess (#58)
|
|
45
|
+
allowed_schemes = ("https://", "http://", "git@", "ssh://", "git://")
|
|
46
|
+
if not any(url.startswith(s) for s in allowed_schemes):
|
|
47
|
+
raise click.ClickException(
|
|
48
|
+
f"Invalid git URL '{url}'. Must start with one of: {', '.join(allowed_schemes)}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_echo(f"[librarian] Cloning {url} ...")
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["git", "clone", "--quiet", url, cloned_dir], # --quiet flag (#53)
|
|
54
|
+
capture_output=True, text=True
|
|
55
|
+
)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
raise click.ClickException(
|
|
58
|
+
f"git clone failed:\n{result.stderr.strip()}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# 1. Extract context from source BEFORE retrofit
|
|
62
|
+
_echo("[librarian] Extracting context from source docs ...")
|
|
63
|
+
context = Librarian._extract_source_context(cloned_dir)
|
|
64
|
+
|
|
65
|
+
# 2. Retrofit if needed
|
|
66
|
+
profile, mapping, _ = Librarian.propose_retrofit(cloned_dir)
|
|
67
|
+
if mapping:
|
|
68
|
+
_echo(f"[librarian] Retrofitting (profile: {profile}) ...")
|
|
69
|
+
Librarian(cloned_dir).migrate(mapping)
|
|
70
|
+
|
|
71
|
+
# 3. Promote sub-agent .md files and create skill-manifest.json stubs
|
|
72
|
+
_echo("[librarian] Generating skill manifests ...")
|
|
73
|
+
Librarian._auto_stub_skill_manifests(cloned_dir, context)
|
|
74
|
+
|
|
75
|
+
# 4. Init + patch manifest description + sync resources
|
|
76
|
+
_echo("[librarian] Initialising agent manifest ...")
|
|
77
|
+
Librarian._auto_init_agent_manifest(cloned_dir, name, context)
|
|
78
|
+
|
|
79
|
+
# 5. Wrap into a ZIP
|
|
80
|
+
_echo("[librarian] Wrapping into portable unit ...")
|
|
81
|
+
zip_path = Librarian(cloned_dir).wrap(tmp_dir)
|
|
82
|
+
|
|
83
|
+
def _cleanup():
|
|
84
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
85
|
+
|
|
86
|
+
return str(zip_path), _cleanup
|
|
87
|
+
|
|
88
|
+
except click.ClickException:
|
|
89
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
90
|
+
raise
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
93
|
+
raise click.ClickException(f"Preparation failed: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Helpers: security
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _assert_within_root(rel_path: str, root: Path) -> None:
|
|
101
|
+
"""Raise ClickException if rel_path resolves outside root (path traversal guard, #59)."""
|
|
102
|
+
resolved = (root / rel_path).resolve()
|
|
103
|
+
if not str(resolved).startswith(str(root.resolve())):
|
|
104
|
+
raise click.ClickException(
|
|
105
|
+
f"Path '{rel_path}' resolves outside the agent root — possible path traversal."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# CLI group
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
@click.group()
|
|
114
|
+
@click.option("--quiet", "-q", is_flag=True, default=False, help="Suppress informational output.")
|
|
115
|
+
@click.pass_context
|
|
116
|
+
def cli(ctx: click.Context, quiet: bool):
|
|
117
|
+
"""Agent Factory — Librarian-powered agent lifecycle manager."""
|
|
118
|
+
ctx.ensure_object(dict)
|
|
119
|
+
ctx.obj["quiet"] = quiet
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _echo(msg: str, *, err: bool = False) -> None:
|
|
123
|
+
"""Print msg unless the current Click context has quiet=True."""
|
|
124
|
+
ctx = click.get_current_context(silent=True)
|
|
125
|
+
if ctx and ctx.obj and ctx.obj.get("quiet") and not err:
|
|
126
|
+
return
|
|
127
|
+
click.echo(msg, err=err)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# init
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
@cli.command("init")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--project-root",
|
|
137
|
+
default=".",
|
|
138
|
+
show_default=True,
|
|
139
|
+
help="Root of the project to initialize (defaults to current directory).",
|
|
140
|
+
)
|
|
141
|
+
def init_project(project_root: str):
|
|
142
|
+
"""
|
|
143
|
+
Initialize the AgentFactory harness in a project.
|
|
144
|
+
|
|
145
|
+
Creates .ai/ with all harness directories, a single .ai/AgentFactory.md
|
|
146
|
+
source of truth, and wires root symlinks so every CLI tool (Claude,
|
|
147
|
+
Codex, Gemini) reads the same shared context file.
|
|
148
|
+
|
|
149
|
+
Safe to run on an existing project — skips items that already exist.
|
|
150
|
+
"""
|
|
151
|
+
import json as _json
|
|
152
|
+
from datetime import datetime, timezone
|
|
153
|
+
|
|
154
|
+
project_path = Path(project_root).resolve()
|
|
155
|
+
ai_root = project_path / HARNESS_ROOT
|
|
156
|
+
|
|
157
|
+
_echo(f"[librarian] Initializing harness at {project_path} ...")
|
|
158
|
+
|
|
159
|
+
# 1. Create .ai/ directory tree
|
|
160
|
+
dirs = [
|
|
161
|
+
ai_root / "adapters" / "claude",
|
|
162
|
+
ai_root / "adapters" / "gemini",
|
|
163
|
+
ai_root / "adapters" / "codex",
|
|
164
|
+
ai_root / "rules",
|
|
165
|
+
ai_root / "commands",
|
|
166
|
+
ai_root / "skills",
|
|
167
|
+
ai_root / "agents",
|
|
168
|
+
ai_root / "memory",
|
|
169
|
+
]
|
|
170
|
+
for d in dirs:
|
|
171
|
+
created = not d.exists()
|
|
172
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
if created:
|
|
174
|
+
(d / ".gitkeep").touch()
|
|
175
|
+
_echo(f" [ok] {d.relative_to(project_path)}")
|
|
176
|
+
|
|
177
|
+
# 2. Scaffold milestones.md traceability matrix in .ai/memory/
|
|
178
|
+
milestones_path = ai_root / "memory" / "milestones.md"
|
|
179
|
+
if not milestones_path.exists():
|
|
180
|
+
milestones_path.write_text(
|
|
181
|
+
"# Milestones\n"
|
|
182
|
+
"<!-- version: 1.0.0 -->\n\n"
|
|
183
|
+
"Traceability matrix for all project issues and work items.\n"
|
|
184
|
+
"Update on every PR merge and release (see git-versioning SKILL.md Step 8.5).\n\n"
|
|
185
|
+
"## Completed\n\n"
|
|
186
|
+
"| # | Title | Type | PR | Commit | Tag |\n"
|
|
187
|
+
"|---|-------|------|----|--------|-----|\n"
|
|
188
|
+
"| — | _No items yet_ | — | — | — | — |\n\n"
|
|
189
|
+
"## Pending\n\n"
|
|
190
|
+
"| # | Title | Type | Status | Phase | Branch | PR | Commit | Tag |\n"
|
|
191
|
+
"|---|-------|------|--------|-------|--------|----|--------|-----|\n"
|
|
192
|
+
"| — | _No items yet_ | — | — | — | — | — | — | — |\n",
|
|
193
|
+
encoding="utf-8",
|
|
194
|
+
)
|
|
195
|
+
_echo(" [created] .ai/memory/milestones.md")
|
|
196
|
+
else:
|
|
197
|
+
_echo(" [skip] .ai/memory/milestones.md (already exists)")
|
|
198
|
+
|
|
199
|
+
# 3. Create single AgentFactory.md source of truth
|
|
200
|
+
context_path = ai_root / CONTEXT_FILE
|
|
201
|
+
if not context_path.exists():
|
|
202
|
+
context_path.write_text(
|
|
203
|
+
"# Project Context\n\n"
|
|
204
|
+
"<!-- @agent-registry:start -->\n"
|
|
205
|
+
"_No agents currently imported._\n"
|
|
206
|
+
"<!-- @agent-registry:end -->\n\n"
|
|
207
|
+
"<!-- @skills-registry:start -->\n"
|
|
208
|
+
"_No global skills currently imported._\n"
|
|
209
|
+
"<!-- @skills-registry:end -->\n",
|
|
210
|
+
encoding="utf-8",
|
|
211
|
+
)
|
|
212
|
+
_echo(f" [created] {context_path.relative_to(project_path)}")
|
|
213
|
+
else:
|
|
214
|
+
_echo(f" [skip] {context_path.relative_to(project_path)} (already exists)")
|
|
215
|
+
|
|
216
|
+
# 3. Create .ai/agent-manifest.json
|
|
217
|
+
global_manifest_path = Librarian._global_manifest_path(str(project_path))
|
|
218
|
+
if not global_manifest_path.exists():
|
|
219
|
+
manifest = {
|
|
220
|
+
"factory": "AgentFactory",
|
|
221
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
222
|
+
"agents": {},
|
|
223
|
+
}
|
|
224
|
+
with open(global_manifest_path, "w") as f:
|
|
225
|
+
_json.dump(manifest, f, indent=2)
|
|
226
|
+
_echo(f" [created] {global_manifest_path.relative_to(project_path)}")
|
|
227
|
+
else:
|
|
228
|
+
_echo(f" [skip] {global_manifest_path.relative_to(project_path)} (already exists)")
|
|
229
|
+
|
|
230
|
+
# 4. Root symlinks — all four .md files point to .ai/AgentFactory.md
|
|
231
|
+
root_links = {
|
|
232
|
+
"CLAUDE.md": f"{HARNESS_ROOT}/{CONTEXT_FILE}",
|
|
233
|
+
"AGENTS.md": f"{HARNESS_ROOT}/{CONTEXT_FILE}",
|
|
234
|
+
"GEMINI.md": f"{HARNESS_ROOT}/{CONTEXT_FILE}",
|
|
235
|
+
"CODEX.md": f"{HARNESS_ROOT}/{CONTEXT_FILE}",
|
|
236
|
+
".claude": f"{HARNESS_ROOT}/adapters/claude",
|
|
237
|
+
".gemini": f"{HARNESS_ROOT}/adapters/gemini",
|
|
238
|
+
".codex": f"{HARNESS_ROOT}/adapters/codex",
|
|
239
|
+
}
|
|
240
|
+
for link_name, target in root_links.items():
|
|
241
|
+
link_path = project_path / link_name
|
|
242
|
+
if link_path.exists() or link_path.is_symlink():
|
|
243
|
+
_echo(f" [skip] {link_name} (already exists)")
|
|
244
|
+
continue
|
|
245
|
+
link_path.symlink_to(target)
|
|
246
|
+
_echo(f" [linked] {link_name} -> {target}")
|
|
247
|
+
|
|
248
|
+
# 5. Ensure adapter capability symlinks are wired
|
|
249
|
+
Librarian._ensure_adapter_wiring(str(project_path))
|
|
250
|
+
_echo(" [wired] adapter capability symlinks")
|
|
251
|
+
|
|
252
|
+
_echo("\n[librarian] Harness ready. Run 'agent-gen deploy <name>' to scaffold your first agent.")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# deploy
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
@cli.command()
|
|
260
|
+
@click.argument("name")
|
|
261
|
+
def deploy(name: str):
|
|
262
|
+
"""
|
|
263
|
+
Deploy a new agent directory and initialise its manifest.
|
|
264
|
+
|
|
265
|
+
Creates: agents/<name>/{skills,commands,docs,scripts,orchestration}/ + agent-manifest.json
|
|
266
|
+
"""
|
|
267
|
+
root = _agent_root(name)
|
|
268
|
+
|
|
269
|
+
if root.exists():
|
|
270
|
+
_echo(f"[librarian] Agent '{name}' already exists at {root}.", err=True)
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
# Scaffold directories
|
|
274
|
+
for d in TRACKED_DIRS:
|
|
275
|
+
(root / d).mkdir(parents=True)
|
|
276
|
+
(root / d / ".gitkeep").touch()
|
|
277
|
+
|
|
278
|
+
# Initialise manifest
|
|
279
|
+
librarian = Librarian(str(root))
|
|
280
|
+
manifest = librarian.init(name)
|
|
281
|
+
|
|
282
|
+
_echo(f"[librarian] Deployed '{name}'")
|
|
283
|
+
_echo(f" Root : {root}")
|
|
284
|
+
_echo(f" Version: {manifest['version']}")
|
|
285
|
+
_echo(f" git_ref: {manifest['git_ref']}")
|
|
286
|
+
_echo(f" Manifest: {root / 'agent-manifest.json'}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# describe
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
@cli.command()
|
|
294
|
+
@click.argument("name")
|
|
295
|
+
@click.option("--desc", help="Description of the agent.")
|
|
296
|
+
@click.option("--plan", help="Path to the orchestration plan (relative to agent root).")
|
|
297
|
+
def describe(name: str, desc: str, plan: str):
|
|
298
|
+
"""
|
|
299
|
+
Update agent metadata (description, orchestration plan).
|
|
300
|
+
"""
|
|
301
|
+
root = _agent_root(name)
|
|
302
|
+
librarian = Librarian(str(root))
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
manifest = librarian._load_manifest()
|
|
306
|
+
except FileNotFoundError as exc:
|
|
307
|
+
_echo(f"[librarian] {exc}", err=True)
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
if desc:
|
|
311
|
+
manifest["description"] = desc
|
|
312
|
+
if plan:
|
|
313
|
+
_assert_within_root(plan, root) # path traversal guard (#59)
|
|
314
|
+
if not (root / plan).exists():
|
|
315
|
+
_echo(f"[librarian] Warning: plan path does not exist: {plan}", err=True)
|
|
316
|
+
manifest["orchestration_plan"] = plan
|
|
317
|
+
|
|
318
|
+
librarian._save_manifest(manifest)
|
|
319
|
+
Librarian.sync_to_global(manifest, ".")
|
|
320
|
+
_echo(f"[librarian] Updated metadata for '{name}'")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@cli.command()
|
|
324
|
+
@click.argument("name")
|
|
325
|
+
def uninstall(name: str):
|
|
326
|
+
"""
|
|
327
|
+
Remove an agent and its registration.
|
|
328
|
+
"""
|
|
329
|
+
root = _agent_root(name)
|
|
330
|
+
librarian = Librarian(str(root))
|
|
331
|
+
|
|
332
|
+
if not root.exists():
|
|
333
|
+
_echo(f"[librarian] Agent '{name}' not found at {root}.", err=True)
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
if not click.confirm(f"[librarian] Are you sure you want to uninstall '{name}'?"):
|
|
337
|
+
_echo("[librarian] Aborted.")
|
|
338
|
+
sys.exit(0)
|
|
339
|
+
|
|
340
|
+
_echo(f"[librarian] Uninstalling '{name}'...")
|
|
341
|
+
librarian.uninstall()
|
|
342
|
+
Librarian.update_harness_files(".")
|
|
343
|
+
_echo(f"[librarian] Uninstalled '{name}'.")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@cli.command()
|
|
347
|
+
@click.argument("name")
|
|
348
|
+
def audit(name: str):
|
|
349
|
+
"""
|
|
350
|
+
Check an agent for integrity drift and missing metadata.
|
|
351
|
+
"""
|
|
352
|
+
root = _agent_root(name)
|
|
353
|
+
librarian = Librarian(str(root))
|
|
354
|
+
|
|
355
|
+
_echo(f"[librarian] Auditing '{name}'...")
|
|
356
|
+
report = librarian.audit()
|
|
357
|
+
|
|
358
|
+
clean = True
|
|
359
|
+
|
|
360
|
+
if report["broken_resources"]:
|
|
361
|
+
clean = False
|
|
362
|
+
_echo("[librarian] ✗ Broken resources (listed in manifest but missing on disk):", err=True)
|
|
363
|
+
for p in report["broken_resources"]:
|
|
364
|
+
_echo(f" - {p}", err=True)
|
|
365
|
+
|
|
366
|
+
if report["broken_dependencies"]:
|
|
367
|
+
clean = False
|
|
368
|
+
_echo("[librarian] ✗ Broken dependencies (cross-file references not found):", err=True)
|
|
369
|
+
for p in report["broken_dependencies"]:
|
|
370
|
+
_echo(f" - {p}", err=True)
|
|
371
|
+
|
|
372
|
+
if report["untracked_files"]:
|
|
373
|
+
clean = False
|
|
374
|
+
_echo("[librarian] ! Untracked files (exist on disk but missing from manifest):")
|
|
375
|
+
for p in report["untracked_files"]:
|
|
376
|
+
_echo(f" - {p} (Run 'wrap' or 'sync' to add them)")
|
|
377
|
+
|
|
378
|
+
if report["missing_skill_manifests"]:
|
|
379
|
+
clean = False
|
|
380
|
+
_echo("[librarian] ! Missing skill-manifest.json in directories:")
|
|
381
|
+
for p in report["missing_skill_manifests"]:
|
|
382
|
+
_echo(f" - {p}")
|
|
383
|
+
|
|
384
|
+
if report.get("invalid_skill_manifests"):
|
|
385
|
+
clean = False
|
|
386
|
+
_echo("[librarian] ✗ Invalid skill manifests (missing 'name' or 'description'):", err=True)
|
|
387
|
+
for p in report["invalid_skill_manifests"]:
|
|
388
|
+
_echo(f" - {p}", err=True)
|
|
389
|
+
|
|
390
|
+
if clean:
|
|
391
|
+
_echo(f"[librarian] ✓ '{name}' integrity is clean.")
|
|
392
|
+
else:
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
# wrap
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
@cli.command()
|
|
401
|
+
@click.argument("name")
|
|
402
|
+
@click.option(
|
|
403
|
+
"--out",
|
|
404
|
+
default=".",
|
|
405
|
+
show_default=True,
|
|
406
|
+
help="Directory to write the archive to.",
|
|
407
|
+
)
|
|
408
|
+
def wrap(name: str, out: str):
|
|
409
|
+
"""
|
|
410
|
+
Validate and bundle an agent into a portable .zip package.
|
|
411
|
+
"""
|
|
412
|
+
root = _agent_root(name)
|
|
413
|
+
librarian = Librarian(str(root))
|
|
414
|
+
|
|
415
|
+
_echo(f"[librarian] Syncing manifest for '{name}'...")
|
|
416
|
+
librarian.sync()
|
|
417
|
+
|
|
418
|
+
_echo(f"[librarian] Auditing '{name}'...")
|
|
419
|
+
report = librarian.audit()
|
|
420
|
+
|
|
421
|
+
if report["broken_resources"] or report["broken_dependencies"] or report.get("invalid_skill_manifests"):
|
|
422
|
+
_echo("[librarian] Audit failed — fix broken paths, dependencies, or invalid skill manifests before wrapping.", err=True)
|
|
423
|
+
_echo(f"[librarian] Run 'agent-gen audit {name}' for details.", err=True)
|
|
424
|
+
sys.exit(1)
|
|
425
|
+
|
|
426
|
+
_echo("[librarian] Audit passed.")
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
archive = librarian.wrap(out)
|
|
430
|
+
except FileNotFoundError as exc:
|
|
431
|
+
_echo(f"[librarian] {exc}", err=True)
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
_echo(f"[librarian] Wrapped → {archive}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@cli.command()
|
|
438
|
+
@click.argument("path")
|
|
439
|
+
@click.option("--yes", is_flag=True, help="Execute migration without asking for confirmation.")
|
|
440
|
+
def retrofit(path: str, yes: bool):
|
|
441
|
+
"""
|
|
442
|
+
Ingest and standardize an existing agent directory.
|
|
443
|
+
|
|
444
|
+
Heuristically detects Claude/Gemini/Codex structures and maps them
|
|
445
|
+
to the AgentFactory standard.
|
|
446
|
+
"""
|
|
447
|
+
source_path = Path(path).resolve()
|
|
448
|
+
if not source_path.exists():
|
|
449
|
+
_echo(f"[librarian] Path not found: {source_path}", err=True)
|
|
450
|
+
sys.exit(1)
|
|
451
|
+
|
|
452
|
+
_echo(f"[librarian] Analyzing {source_path}...")
|
|
453
|
+
profile, mapping, all_profiles = Librarian.propose_retrofit(str(source_path))
|
|
454
|
+
|
|
455
|
+
_echo(f"[librarian] Detected profile: {profile.upper()}")
|
|
456
|
+
if len(all_profiles) > 1:
|
|
457
|
+
_echo(
|
|
458
|
+
f"[librarian] Warning: multiple profiles matched: {all_profiles}. "
|
|
459
|
+
f"Using '{profile}' — pass --profile to override.",
|
|
460
|
+
err=True,
|
|
461
|
+
)
|
|
462
|
+
_echo("[librarian] Proposed Mapping:")
|
|
463
|
+
for src, dest in mapping.items():
|
|
464
|
+
_echo(f" - {src} -> {dest}")
|
|
465
|
+
|
|
466
|
+
if not yes:
|
|
467
|
+
if not click.confirm("[librarian] Proceed with migration?"):
|
|
468
|
+
_echo("[librarian] Aborted.")
|
|
469
|
+
sys.exit(0)
|
|
470
|
+
|
|
471
|
+
# In-place migration
|
|
472
|
+
librarian = Librarian(str(source_path))
|
|
473
|
+
librarian.migrate(mapping)
|
|
474
|
+
|
|
475
|
+
# Initialize manifest if missing, otherwise sync
|
|
476
|
+
if not librarian.manifest_path.exists():
|
|
477
|
+
name = source_path.name
|
|
478
|
+
librarian.init(name)
|
|
479
|
+
|
|
480
|
+
librarian.sync()
|
|
481
|
+
|
|
482
|
+
_echo(f"[librarian] Retrofit complete for '{source_path.name}'.")
|
|
483
|
+
_echo(f" Standardized into {TRACKED_DIRS}")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# import-skill
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
@cli.command("import-skill")
|
|
491
|
+
@click.argument("path")
|
|
492
|
+
@click.option(
|
|
493
|
+
"--to",
|
|
494
|
+
"target_agent",
|
|
495
|
+
default=".",
|
|
496
|
+
help="Name of the target agent to receive the skill. Defaults to '.' (the root project).",
|
|
497
|
+
)
|
|
498
|
+
def import_skill(path: str, target_agent: str):
|
|
499
|
+
"""
|
|
500
|
+
Import a standalone skill into an existing agent or the root project.
|
|
501
|
+
"""
|
|
502
|
+
path_obj = Path(path).resolve()
|
|
503
|
+
if not path_obj.exists():
|
|
504
|
+
_echo(f"[librarian] Skill path not found: {path_obj}", err=True)
|
|
505
|
+
sys.exit(1)
|
|
506
|
+
|
|
507
|
+
if target_agent == ".":
|
|
508
|
+
target_root = Path.cwd() / HARNESS_ROOT
|
|
509
|
+
_echo(f"[librarian] Importing skill from {path_obj} into root project...")
|
|
510
|
+
# Perform root-level skill import logic manually to avoid overwriting global manifest
|
|
511
|
+
import tempfile
|
|
512
|
+
import shutil
|
|
513
|
+
import zipfile
|
|
514
|
+
import json
|
|
515
|
+
|
|
516
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
517
|
+
temp_path = Path(temp_dir)
|
|
518
|
+
if zipfile.is_zipfile(path_obj):
|
|
519
|
+
with zipfile.ZipFile(path_obj, 'r') as zf:
|
|
520
|
+
zf.extractall(temp_path)
|
|
521
|
+
elif path_obj.is_dir():
|
|
522
|
+
shutil.copytree(path_obj, temp_path, dirs_exist_ok=True)
|
|
523
|
+
else:
|
|
524
|
+
_echo("[librarian] Skill source must be a directory or a ZIP file.", err=True)
|
|
525
|
+
sys.exit(1)
|
|
526
|
+
|
|
527
|
+
manifest_file = temp_path / "skill-manifest.json"
|
|
528
|
+
if not manifest_file.exists():
|
|
529
|
+
manifests = list(temp_path.rglob("skill-manifest.json"))
|
|
530
|
+
if not manifests:
|
|
531
|
+
_echo("[librarian] skill-manifest.json not found in the source.", err=True)
|
|
532
|
+
sys.exit(1)
|
|
533
|
+
manifest_file = manifests[0]
|
|
534
|
+
temp_path = manifest_file.parent
|
|
535
|
+
|
|
536
|
+
with open(manifest_file) as f:
|
|
537
|
+
skill_data = json.load(f)
|
|
538
|
+
|
|
539
|
+
skill_name = skill_data["name"]
|
|
540
|
+
target_dir = target_root / "skills" / skill_name
|
|
541
|
+
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
542
|
+
|
|
543
|
+
if target_dir.exists():
|
|
544
|
+
shutil.rmtree(target_dir)
|
|
545
|
+
|
|
546
|
+
shutil.copytree(temp_path, target_dir)
|
|
547
|
+
|
|
548
|
+
_echo(f"[librarian] Successfully imported skill '{skill_name}' into root project.")
|
|
549
|
+
_echo(f" Path: {target_root / 'skills' / skill_name}") # .ai/skills/<name>
|
|
550
|
+
|
|
551
|
+
# update_harness_files takes the project root (not the .ai/ sub-path)
|
|
552
|
+
Librarian.update_harness_files(str(Path.cwd()))
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
target_root = _agent_root(target_agent)
|
|
556
|
+
if not target_root.exists():
|
|
557
|
+
_echo(f"[librarian] Target agent '{target_agent}' not found at {target_root}.", err=True)
|
|
558
|
+
sys.exit(1)
|
|
559
|
+
|
|
560
|
+
librarian = Librarian(str(target_root))
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
_echo(f"[librarian] Importing skill from {path_obj} into '{target_agent}'...")
|
|
564
|
+
skill_name = librarian.import_skill(str(path_obj))
|
|
565
|
+
_echo(f"[librarian] Successfully imported skill '{skill_name}' into '{target_agent}'.")
|
|
566
|
+
_echo(f" Path: {target_root / 'skills' / skill_name}")
|
|
567
|
+
except Exception as exc:
|
|
568
|
+
_echo(f"[librarian] Skill import failed: {exc}", err=True)
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
# import (reserved word workaround)
|
|
574
|
+
# ---------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
@cli.command("import")
|
|
577
|
+
@click.argument("zip_path", required=False, default=None)
|
|
578
|
+
@click.option(
|
|
579
|
+
"--from-git",
|
|
580
|
+
"from_git",
|
|
581
|
+
default=None,
|
|
582
|
+
metavar="URL",
|
|
583
|
+
help="Git URL to clone, retrofit, and import as an agent (skips ZIP_PATH).",
|
|
584
|
+
)
|
|
585
|
+
@click.option(
|
|
586
|
+
"--project-root",
|
|
587
|
+
default=".",
|
|
588
|
+
show_default=True,
|
|
589
|
+
help="Root of the project receiving the import (for the Handshake).",
|
|
590
|
+
)
|
|
591
|
+
def import_agent(zip_path: str, from_git: str, project_root: str):
|
|
592
|
+
"""
|
|
593
|
+
Unpack a portable agent bundle and register it in this project.
|
|
594
|
+
|
|
595
|
+
Accepts either a local ZIP_PATH or --from-git URL (git clone + auto-retrofit).
|
|
596
|
+
|
|
597
|
+
Steps:
|
|
598
|
+
1. Unpack <zip_path> into agents/<name>/
|
|
599
|
+
2. Read the embedded manifest
|
|
600
|
+
3. Handshake: update this project's global agent-manifest.json
|
|
601
|
+
"""
|
|
602
|
+
_cleanup = None
|
|
603
|
+
|
|
604
|
+
if not zip_path and not from_git:
|
|
605
|
+
raise click.UsageError("Provide either ZIP_PATH or --from-git URL.")
|
|
606
|
+
if zip_path and from_git:
|
|
607
|
+
raise click.UsageError("ZIP_PATH and --from-git are mutually exclusive.")
|
|
608
|
+
|
|
609
|
+
if from_git:
|
|
610
|
+
zip_path, _cleanup = _clone_and_prepare(from_git)
|
|
611
|
+
|
|
612
|
+
zip_path = Path(zip_path).resolve()
|
|
613
|
+
|
|
614
|
+
if not zip_path.exists():
|
|
615
|
+
_echo(f"[librarian] File not found: {zip_path}", err=True)
|
|
616
|
+
sys.exit(1)
|
|
617
|
+
|
|
618
|
+
# Peek at the archive to get the agent name before unpacking
|
|
619
|
+
import zipfile
|
|
620
|
+
import json
|
|
621
|
+
|
|
622
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
623
|
+
with zf.open("agent-manifest.json") as mf:
|
|
624
|
+
peeked = json.load(mf)
|
|
625
|
+
|
|
626
|
+
name = peeked["name"]
|
|
627
|
+
target_root = _agent_root(name)
|
|
628
|
+
|
|
629
|
+
if target_root.exists():
|
|
630
|
+
_echo(
|
|
631
|
+
f"[librarian] '{name}' already exists at {target_root}. "
|
|
632
|
+
"Remove it first or rename the incoming bundle.",
|
|
633
|
+
err=True,
|
|
634
|
+
)
|
|
635
|
+
sys.exit(1)
|
|
636
|
+
|
|
637
|
+
import tempfile
|
|
638
|
+
import shutil
|
|
639
|
+
|
|
640
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
641
|
+
temp_root = Path(temp_dir) / name
|
|
642
|
+
temp_root.mkdir(parents=True)
|
|
643
|
+
|
|
644
|
+
_echo(f"[librarian] Unpacking '{name}'...")
|
|
645
|
+
manifest = Librarian.unpack(str(zip_path), str(temp_root))
|
|
646
|
+
|
|
647
|
+
# Deep Audit before finalizing
|
|
648
|
+
_echo(f"[librarian] Auditing '{name}' before registration...")
|
|
649
|
+
librarian = Librarian(str(temp_root))
|
|
650
|
+
report = librarian.audit()
|
|
651
|
+
if report["broken_resources"] or report["broken_dependencies"] or report.get("invalid_skill_manifests"):
|
|
652
|
+
_echo("[librarian] Audit FAILED for imported bundle. Aborting...", err=True)
|
|
653
|
+
sys.exit(1)
|
|
654
|
+
|
|
655
|
+
# Move to final location
|
|
656
|
+
target_root.parent.mkdir(parents=True, exist_ok=True)
|
|
657
|
+
shutil.move(str(temp_root), str(target_root))
|
|
658
|
+
|
|
659
|
+
_echo(f"[librarian] Registering '{name}' in project manifest...")
|
|
660
|
+
Librarian.register_in_project(manifest, project_root)
|
|
661
|
+
|
|
662
|
+
# Clean up temp dir from --from-git clone
|
|
663
|
+
if _cleanup:
|
|
664
|
+
_cleanup()
|
|
665
|
+
|
|
666
|
+
# Success summary (paste-ready for Claude)
|
|
667
|
+
skills = manifest["resources"].get("skills", [])
|
|
668
|
+
commands = manifest["resources"].get("commands", [])
|
|
669
|
+
docs = manifest["resources"].get("docs", [])
|
|
670
|
+
scripts = manifest["resources"].get("scripts", [])
|
|
671
|
+
orchestration = manifest["resources"].get("orchestration", [])
|
|
672
|
+
|
|
673
|
+
_echo("")
|
|
674
|
+
_echo("=" * 60)
|
|
675
|
+
_echo("Librarian: Import Complete.")
|
|
676
|
+
_echo(
|
|
677
|
+
f" Added {len(skills)} skill(s), {len(commands)} command(s), "
|
|
678
|
+
f"{len(docs)} doc(s), {len(scripts)} script(s), "
|
|
679
|
+
f"and {len(orchestration)} orchestration file(s) to your environment."
|
|
680
|
+
)
|
|
681
|
+
_echo(f" You are now configured as the '{name}' agent.")
|
|
682
|
+
if docs:
|
|
683
|
+
claude_md = next((d for d in docs if "CLAUDE.md" in d), None)
|
|
684
|
+
if claude_md:
|
|
685
|
+
_echo(f" Check {claude_md} for your new instructions.")
|
|
686
|
+
_echo("=" * 60)
|
|
687
|
+
|
|
688
|
+
if __name__ == "__main__":
|
|
689
|
+
cli()
|