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 ADDED
@@ -0,0 +1 @@
1
+ """AgentFactory — agent_gen package."""
agent_gen/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
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()