agent-foundry-runtime 0.4.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.
@@ -0,0 +1,2 @@
1
+ """Agent Foundry — Skill orchestrator for Claude Code."""
2
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Enable `python -m agent_foundry`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
agent_foundry/cli.py ADDED
@@ -0,0 +1,613 @@
1
+ """Agent Foundry CLI (Click)."""
2
+ from __future__ import annotations
3
+
4
+ import dataclasses
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from .config import Config, init_config, LOG_DB_PATH
14
+ from .indexer import (
15
+ build_index,
16
+ get_index_cached,
17
+ invalidate_cache,
18
+ read_index,
19
+ write_index,
20
+ )
21
+ from .models import (
22
+ ExecuteRequest,
23
+ LoopRequest,
24
+ PlanRequest,
25
+ )
26
+
27
+
28
+ def _ensure_daemon(cfg: Config) -> bool:
29
+ """Best-effort daemon health check (lazy start is run elsewhere)."""
30
+ try:
31
+ import httpx
32
+ url = f"http://127.0.0.1:{cfg.core.daemon_port}/health"
33
+ r = httpx.get(url, timeout=0.5)
34
+ return r.status_code == 200
35
+ except Exception:
36
+ return False
37
+
38
+
39
+ def _start_daemon(cfg: Config) -> bool:
40
+ """Spawn the daemon in background and wait for /health. Returns True if started."""
41
+ import subprocess
42
+ import time as _t
43
+ log_path = Path.home() / ".config" / "agent-foundry" / "daemon.log"
44
+ log_path.parent.mkdir(parents=True, exist_ok=True)
45
+ log_file = log_path.open("ab")
46
+ pid_file = Path.home() / ".config" / "agent-foundry" / "daemon.pid"
47
+ try:
48
+ proc = subprocess.Popen(
49
+ [sys.executable, "-m", "agent_foundry.cli", "serve",
50
+ "--port", str(cfg.core.daemon_port), "--detach"],
51
+ stdout=log_file, stderr=log_file, start_new_session=True,
52
+ )
53
+ pid_file.write_text(str(proc.pid))
54
+ except Exception:
55
+ return False
56
+
57
+ import httpx
58
+ url = f"http://127.0.0.1:{cfg.core.daemon_port}/health"
59
+ for _ in range(50):
60
+ try:
61
+ r = httpx.get(url, timeout=0.5)
62
+ if r.status_code == 200:
63
+ return True
64
+ except Exception:
65
+ pass
66
+ _t.sleep(0.1)
67
+ return False
68
+
69
+
70
+ def _call_or_daemon(cfg: Config, method: str, path: str, payload: dict,
71
+ timeout: float = 30.0) -> dict:
72
+ """POST/GET via daemon (lazy-start if needed)."""
73
+ import httpx
74
+ base = f"http://127.0.0.1:{cfg.core.daemon_port}"
75
+ if not _ensure_daemon(cfg):
76
+ if not _start_daemon(cfg):
77
+ raise click.ClickException(
78
+ f"Could not start daemon on port {cfg.core.daemon_port}"
79
+ )
80
+
81
+ url = f"{base}{path}"
82
+ try:
83
+ if method == "GET":
84
+ r = httpx.get(url, timeout=timeout)
85
+ else:
86
+ r = httpx.post(url, json=payload, timeout=timeout)
87
+ except Exception as e:
88
+ raise click.ClickException(f"HTTP error: {e}")
89
+ if r.status_code not in (200, 400):
90
+ raise click.ClickException(f"Daemon returned {r.status_code}: {r.text[:200]}")
91
+ try:
92
+ return r.json()
93
+ except Exception:
94
+ return {}
95
+
96
+
97
+ @click.group()
98
+ @click.option("--config", "config_path", type=click.Path(exists=False),
99
+ default=None, help="Path to config.toml")
100
+ @click.pass_context
101
+ def cli(ctx, config_path):
102
+ """Agent Foundry — orchestrate skills for Claude Code."""
103
+ ctx.ensure_object(dict)
104
+ if config_path:
105
+ from .config import Config as _Cfg
106
+ cfg = _Cfg.load(Path(config_path))
107
+ else:
108
+ cfg = Config.load()
109
+ ctx.obj["cfg"] = cfg
110
+
111
+
112
+ @cli.command("init")
113
+ @click.option("--force", is_flag=True, help="Overwrite existing config")
114
+ @click.option("--skills-dir", default=None, help="Override skills directory")
115
+ @click.pass_context
116
+ def cmd_init(ctx, force, skills_dir):
117
+ """Create config.toml and build the initial skill index."""
118
+ cfg = ctx.obj["cfg"]
119
+ if skills_dir:
120
+ # Config is frozen — derive a new instance with the override
121
+ cfg = dataclasses.replace(
122
+ cfg, core=dataclasses.replace(cfg.core, skills_dir=skills_dir)
123
+ )
124
+ if force or not cfg.config_path.exists():
125
+ cfg.save()
126
+ click.echo(f"Wrote config: {cfg.config_path}")
127
+ else:
128
+ click.echo(f"Config already exists: {cfg.config_path} (use --force to overwrite)")
129
+
130
+ skills_dir_path = Path(cfg.core.skills_dir).expanduser()
131
+ skills_dir_path.mkdir(parents=True, exist_ok=True)
132
+ click.echo(f"Skills dir: {skills_dir_path}")
133
+ idx = build_index(skills_dir_path)
134
+ out = Path(cfg.core.index_path).expanduser()
135
+ write_index(idx, out)
136
+ invalidate_cache(out)
137
+ click.echo(f"Indexed {len(idx.skills)} skills (incl. virtual fallback) -> {out}")
138
+
139
+
140
+ @cli.command("index")
141
+ @click.pass_context
142
+ def cmd_index(ctx):
143
+ """Rebuild the skill index from disk."""
144
+ cfg = ctx.obj["cfg"]
145
+ skills_dir = Path(cfg.core.skills_dir).expanduser()
146
+ if not skills_dir.exists():
147
+ raise click.ClickException(f"skills_dir does not exist: {skills_dir}")
148
+ idx = build_index(skills_dir)
149
+ out = Path(cfg.core.index_path).expanduser()
150
+ write_index(idx, out)
151
+ invalidate_cache(out)
152
+ click.echo(f"Indexed {len(idx.skills)} skills -> {out}")
153
+
154
+
155
+ @cli.command("cost-report")
156
+ @click.option("--cheapest", type=int, default=None, help="Top N cheapest")
157
+ @click.option("--core", "only_core", is_flag=True)
158
+ @click.option("--optional", "only_optional", is_flag=True)
159
+ @click.pass_context
160
+ def cmd_cost_report(ctx, cheapest, only_core, only_optional):
161
+ """Print a table of every skill with cost estimates."""
162
+ cfg = ctx.obj["cfg"]
163
+ idx = get_index_cached(
164
+ Path(cfg.core.index_path).expanduser(),
165
+ ttl_seconds=cfg.core.index_cache_ttl_seconds,
166
+ )
167
+ if idx is None:
168
+ idx = read_index(Path(cfg.core.index_path).expanduser())
169
+ if idx is None:
170
+ raise click.ClickException("No index. Run `agent-foundry init` first.")
171
+
172
+ skills = idx.skills
173
+ if only_core:
174
+ skills = [s for s in skills if s.category == "core"]
175
+ if only_optional:
176
+ skills = [s for s in skills if s.category == "optional"]
177
+
178
+ def cost(s):
179
+ c = s.estimated_token_cost or {}
180
+ return int(c.get("input", 0)) + int(c.get("output", 0))
181
+
182
+ if cheapest:
183
+ skills = sorted(skills, key=cost)[:cheapest]
184
+
185
+ click.echo(f"{'Skill':<32} {'In':>6} {'Out':>6} {'Total':>7} {'Sec':>5} Cat")
186
+ click.echo(f"{'-'*32} {'-'*6} {'-'*6} {'-'*7} {'-'*5} {'-'*4}")
187
+ ti = to = tt = 0
188
+ for s in skills:
189
+ c = s.estimated_token_cost or {}
190
+ ii = int(c.get("input", 0))
191
+ oo = int(c.get("output", 0))
192
+ ti += ii; to += oo; tt += ii + oo
193
+ click.echo(f"{s.id:<32} {ii:>6} {oo:>6} {ii+oo:>7} {s.estimated_time_seconds:>5} {s.category[:1]}")
194
+ click.echo(f"{'-'*32} {'-'*6} {'-'*6} {'-'*7} {'-'*5}")
195
+ click.echo(f"{'TOTAL (' + str(len(skills)) + ')':<32} {ti:>6} {to:>6} {tt:>7}")
196
+
197
+
198
+ @cli.command("plan")
199
+ @click.argument("prompt")
200
+ @click.option("--max", "max_results", type=int, default=None, help="Top N")
201
+ @click.pass_context
202
+ def cmd_plan(ctx, prompt, max_results):
203
+ """Rank skills for a prompt."""
204
+ cfg = ctx.obj["cfg"]
205
+ if max_results:
206
+ cfg.planner.max_results = max_results
207
+ payload = {"prompt": prompt, "max_results": cfg.planner.max_results}
208
+ data = _call_or_daemon(cfg, "POST", "/plan", payload)
209
+ results = data.get("results", [])
210
+ if not results:
211
+ click.echo(f"No skills matched. (Total available: {data.get('total_available', 0)})")
212
+ return
213
+ for i, r in enumerate(results, 1):
214
+ c = r.get("estimated_cost", {})
215
+ click.echo(
216
+ f"{i}. {r['skill_id']} score={r['score']:.3f} "
217
+ f"cost={c.get('total', 0)} tok ({c.get('input', 0)}in/{c.get('output', 0)}out)"
218
+ )
219
+ if r.get("matched_patterns"):
220
+ click.echo(f" matched: {r['matched_patterns']}")
221
+
222
+
223
+ @cli.command("execute")
224
+ @click.argument("skill_id")
225
+ @click.argument("prompt")
226
+ @click.option("--model", default=None)
227
+ @click.option("--force", is_flag=True, help="Skip budget confirmation")
228
+ @click.pass_context
229
+ def cmd_execute(ctx, skill_id, prompt, model, force):
230
+ """Run a specific skill on a prompt."""
231
+ cfg = ctx.obj["cfg"]
232
+ payload = {"skill_id": skill_id, "prompt": prompt, "force": force, "model": model}
233
+ data = _call_or_daemon(cfg, "POST", "/execute", payload)
234
+
235
+ if data.get("requires_confirmation"):
236
+ c = data.get("estimated_cost", {})
237
+ click.echo(
238
+ f"Estimated cost: {c.get('total', 0)} tokens "
239
+ f"(input {c.get('input')}, output {c.get('output')})."
240
+ )
241
+ if force or not sys.stdin.isatty():
242
+ click.echo("Non-TTY or --force set; declining to avoid hanging.")
243
+ return
244
+ if not click.confirm("Proceed? [y/N]", default=False):
245
+ click.echo("Aborted — no tokens spent.")
246
+ return
247
+ payload["force"] = True
248
+ data = _call_or_daemon(cfg, "POST", "/execute", payload)
249
+
250
+ click.echo(data.get("output", ""))
251
+ if data.get("error"):
252
+ click.echo(f"\n[error] {data['error']}", err=True)
253
+ sys.exit(1)
254
+ click.echo(
255
+ f"\n[tokens={data.get('tokens_used', 0)} "
256
+ f"time={data.get('duration_seconds', 0):.2f}s]",
257
+ err=True,
258
+ )
259
+
260
+
261
+ @cli.command("run")
262
+ @click.argument("prompt")
263
+ @click.option("--model", default=None)
264
+ @click.option("--budget", type=int, default=None)
265
+ @click.option("--force", is_flag=True, help="Skip budget confirmation prompt")
266
+ @click.option("--judge", is_flag=True, help="Score output via af-critic after execution")
267
+ @click.pass_context
268
+ def cmd_run(ctx, prompt, model, budget, force, judge):
269
+ """Plan + execute the top-ranked skill."""
270
+ cfg = ctx.obj["cfg"]
271
+ payload = {"prompt": prompt, "force": force, "model": model,
272
+ "budget": budget, "judge": judge}
273
+
274
+ def call(req):
275
+ return _call_or_daemon(cfg, "POST", "/loop", req)
276
+
277
+ data = call(payload)
278
+ if data.get("requires_confirmation"):
279
+ c = data.get("estimated_cost", {})
280
+ click.echo(
281
+ f"Estimated cost: {c.get('total', 0)} tokens "
282
+ f"(input {c.get('input')}, output {c.get('output')}, budget "
283
+ f"{budget or cfg.core.token_budget})."
284
+ )
285
+ click.echo(
286
+ f"Selected skill: {data.get('skill_id')} "
287
+ f"(plan: {[p['skill_id'] for p in data.get('plan', [])]})"
288
+ )
289
+ if not force and sys.stdin.isatty() and click.confirm("Proceed? [y/N]", default=False):
290
+ payload["force"] = True
291
+ data = call(payload)
292
+ else:
293
+ if not force and not sys.stdin.isatty():
294
+ click.echo("Non-TTY input; declining without --force.")
295
+ else:
296
+ click.echo("Aborted — no tokens spent.")
297
+ return
298
+
299
+ fb = " [FALLBACK]" if data.get("was_fallback") else ""
300
+ plan_skills = [p["skill_id"] for p in data.get("plan", [])]
301
+ click.echo(f"--> Skill: {data.get('skill_id')}{fb} Plan: {plan_skills}")
302
+ if data.get("session_id"):
303
+ click.echo(f" session {data['session_id']} — continue with: "
304
+ f"agent-foundry resume {data['session_id']} \"...\"", err=True)
305
+ click.echo("---")
306
+ click.echo(data.get("output", ""))
307
+ if data.get("error"):
308
+ click.echo(f"\n[error] {data['error']}", err=True)
309
+ sys.exit(1)
310
+ click.echo(
311
+ f"\n[tokens={data.get('tokens_used', 0)} "
312
+ f"time={data.get('duration_seconds', 0):.2f}s]",
313
+ err=True,
314
+ )
315
+ if data.get("judged") and data.get("judge_score"):
316
+ click.echo(f"\nJudge (af-critic): {json.dumps(data['judge_score'], indent=2)}",
317
+ err=True)
318
+
319
+
320
+ @cli.command("resume")
321
+ @click.argument("session_id", type=int)
322
+ @click.argument("prompt")
323
+ @click.option("--model", default=None)
324
+ @click.pass_context
325
+ def cmd_resume(ctx, session_id, prompt, model):
326
+ """Continue a loop session with a follow-up prompt."""
327
+ cfg = ctx.obj["cfg"]
328
+ data = _call_or_daemon(
329
+ cfg, "POST", "/loop/resume",
330
+ {"session_id": session_id, "prompt": prompt, "model": model},
331
+ timeout=max(60.0, float(cfg.core.executor_timeout_seconds) + 15.0),
332
+ )
333
+ click.echo(f"--> Session: {data.get('session_id')} Skill: {data.get('skill_id')}")
334
+ click.echo("---")
335
+ click.echo(data.get("output", ""))
336
+ if data.get("error"):
337
+ click.echo(f"\n[error] {data['error']}", err=True)
338
+ sys.exit(1)
339
+ click.echo(
340
+ f"\n[tokens={data.get('tokens_used', 0)} "
341
+ f"time={data.get('duration_seconds', 0):.2f}s "
342
+ f"cost=${data.get('cost_usd', 0.0):.6f}]",
343
+ err=True,
344
+ )
345
+
346
+
347
+ @cli.command("session")
348
+ @click.argument("session_id", type=int)
349
+ @click.pass_context
350
+ def cmd_session(ctx, session_id):
351
+ """Show a stored loop session."""
352
+ cfg = ctx.obj["cfg"]
353
+ data = _call_or_daemon(cfg, "GET", f"/loop/{session_id}", {})
354
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
355
+
356
+
357
+ @cli.command("doctor")
358
+ @click.pass_context
359
+ def cmd_doctor(ctx):
360
+ """Health-check: config, index, daemon, API key."""
361
+ cfg = ctx.obj.get("cfg") if ctx.obj else None
362
+ if cfg is None:
363
+ import agent_foundry.config as _cfg
364
+ cfg = _cfg.Config.load()
365
+ ok = True
366
+ click.echo("=== Agent Foundry Doctor ===")
367
+ if cfg.config_path.exists():
368
+ click.echo(f"[PASS] Config: {cfg.config_path}")
369
+ else:
370
+ click.echo(f"[FAIL] Config not found: {cfg.config_path}")
371
+ ok = False
372
+ idx_path = cfg.core.index_path
373
+ if os.path.exists(str(idx_path)):
374
+ click.echo(f"[PASS] Index: {idx_path}")
375
+ from .indexer import read_index
376
+ idx = read_index(Path(idx_path).expanduser())
377
+ if idx:
378
+ real = [s for s in idx.skills if not s.is_fallback]
379
+ click.echo(f"[PASS] Skills indexed: {len(real)}")
380
+ else:
381
+ click.echo("[FAIL] Index unreadable")
382
+ ok = False
383
+ else:
384
+ click.echo(f"[WARN] Index not found: {idx_path} (run init)")
385
+ from .logging_db import get_skill_weights
386
+ weights = get_skill_weights(LOG_DB_PATH)
387
+ if weights:
388
+ click.echo(f"[PASS] Learned skill weights: {len(weights)}")
389
+ graph = Path(__file__).resolve().parent.parent / "web" / "graph-stats.js"
390
+ if graph.exists():
391
+ click.echo(f"[PASS] Knowledge graph stats: {graph.name}")
392
+ else:
393
+ click.echo("[WARN] Knowledge graph stats missing (run graphify)")
394
+ import httpx
395
+ try:
396
+ r = httpx.get(f"http://127.0.0.1:{cfg.core.daemon_port}/health", timeout=2)
397
+ click.echo(f"[PASS] Daemon reachable at port {cfg.core.daemon_port}")
398
+ except Exception:
399
+ click.echo(f"[WARN] Daemon not reachable (lazy-start on next command)")
400
+ import os as _os
401
+ ak = _os.environ.get("ANTHROPIC_API_KEY", _os.environ.get("OPENAI_API_KEY", ""))
402
+ if ak:
403
+ click.echo(f"[PASS] API key set ({ak[:8]}...)")
404
+ else:
405
+ click.echo(f"[FAIL] No API key set (ANTHROPIC_API_KEY or OPENAI_API_KEY)")
406
+ ok = False
407
+ if ok:
408
+ click.echo("\nResult: All checks passed.")
409
+ else:
410
+ click.echo("\nResult: Some checks failed — see above.")
411
+
412
+ @cli.command("status")
413
+ @click.option("--markdown", is_flag=True, help="Output in portable markdown")
414
+ @click.pass_context
415
+ def cmd_status(ctx, markdown):
416
+ """Show orchestrator status: routing accuracy, fallback rate."""
417
+ from .logging_db import LOG_DB_PATH, get_stats
418
+ cfg = ctx.obj.get("cfg") if ctx.obj else None
419
+ if cfg is None:
420
+ import agent_foundry.config as _cfg
421
+ cfg = _cfg.Config.load()
422
+ db_path = LOG_DB_PATH
423
+ stats = get_stats(db_path)
424
+ if markdown:
425
+ click.echo("## Agent Foundry Status\n")
426
+ click.echo(f"| Metric | Value |\n|--------|-------|")
427
+ for k, v in stats.items():
428
+ click.echo(f"| {k} | {v} |")
429
+ else:
430
+ click.echo("=== Agent Foundry Status ===")
431
+ for k, v in stats.items():
432
+ click.echo(f" {k}: {v}")
433
+
434
+ @cli.command("consult")
435
+ @click.argument("need")
436
+ @click.pass_context
437
+ def cmd_consult(ctx, need):
438
+ """Recommend components to install or enable for a need."""
439
+ from agent_foundry.planner import plan
440
+ from agent_foundry.config import Config
441
+ from agent_foundry.indexer import read_index
442
+ from agent_foundry.models import PlanRequest
443
+ from pathlib import Path
444
+
445
+ cfg = ctx.obj.get("cfg") if ctx.obj else None
446
+ if cfg is None:
447
+ cfg = Config.load()
448
+ idx = read_index(Path(cfg.core.index_path).expanduser())
449
+ if not idx:
450
+ click.echo("No index found. Run 'init' first.", err=True)
451
+ return
452
+ req = PlanRequest(prompt=need)
453
+ resp = plan(req, idx, cfg.planner)
454
+ click.echo(f"Components recommended for: {need}\n")
455
+ for r in resp.results[:5]:
456
+ click.echo(f" {r.skill_id} (score {r.score})")
457
+ click.echo(f" {r.description[:100]}")
458
+ click.echo(f"\nTo install: agent-foundry install {r.skill_id} (TODO)")
459
+
460
+
461
+ @cli.command("serve")
462
+
463
+ @click.option("--port", type=int, default=None, help="Override daemon port")
464
+ @click.option("--host", default="127.0.0.1", help="Bind host")
465
+ @click.option("--detach", is_flag=True, help="Detach mode (used by lazy-start)")
466
+ @click.pass_context
467
+ def cmd_serve(ctx, port, host, detach):
468
+ """Run the daemon (foreground by default)."""
469
+ import uvicorn
470
+
471
+ cfg = ctx.obj.get("cfg") if ctx.obj else None
472
+ if cfg is None:
473
+ cfg = Config.load()
474
+ if port:
475
+ # Config is frozen — derive a new instance with the override
476
+ cfg = dataclasses.replace(
477
+ cfg, core=dataclasses.replace(cfg.core, daemon_port=port)
478
+ )
479
+
480
+ from .daemon import create_app
481
+ app = create_app(cfg)
482
+
483
+ if detach:
484
+ config = uvicorn.Config(
485
+ app, host=host, port=cfg.core.daemon_port, log_level="warning"
486
+ )
487
+ server = uvicorn.Server(config)
488
+ server.run()
489
+ else:
490
+ click.echo(
491
+ f"Agent Foundry daemon listening on http://{host}:{cfg.core.daemon_port}"
492
+ )
493
+ uvicorn.run(app, host=host, port=cfg.core.daemon_port)
494
+
495
+
496
+ @cli.command("feedback")
497
+ @click.argument("execution_id", type=int)
498
+ @click.argument("score", type=int)
499
+ @click.pass_context
500
+ def cmd_feedback(ctx, execution_id, score):
501
+ """Rate an execution: 1 = thumbs_up, -1 = thumbs_down."""
502
+ if score not in (1, -1):
503
+ click.echo("Score must be 1 (up) or -1 (down)", err=True)
504
+ ctx.exit(1)
505
+ from .logging_db import set_feedback, LOG_DB_PATH
506
+ ok = set_feedback(LOG_DB_PATH, execution_id, score)
507
+ click.echo("✓ Feedback recorded" if ok else "✗ Execution not found")
508
+
509
+
510
+ @cli.command("learn")
511
+ @click.pass_context
512
+ def cmd_learn(ctx):
513
+ """Analyze execution log and generate trigger-pattern instincts."""
514
+ from .logging_db import learn, LOG_DB_PATH
515
+ result = learn(LOG_DB_PATH)
516
+ click.echo(f"New instincts: {result['new_instincts']}")
517
+ click.echo(f"Updated: {result['updated_instincts']}")
518
+ click.echo(f"Skill weights updated: {result.get('weights_updated', 0)}")
519
+ for p in result["patterns_found"]:
520
+ click.echo(f" {p['skill']} → \"{p['pattern']}\"")
521
+
522
+
523
+ @cli.command("instincts")
524
+ @click.option("--approved", is_flag=True, help="Show approved only")
525
+ @click.pass_context
526
+ def cmd_instincts(ctx, approved):
527
+ """List learned trigger-pattern instincts."""
528
+ from .logging_db import list_instincts, LOG_DB_PATH
529
+ for inst in list_instincts(LOG_DB_PATH, approved_only=approved):
530
+ flag = "✓" if inst["approved"] else "○"
531
+ click.echo(f" {flag} #{inst['id']} [{inst['skill']}] \"{inst['pattern']}\" conf={inst['confidence']:.2f} ({inst['samples']} samples)")
532
+
533
+
534
+ @cli.command("instinct-approve")
535
+ @click.argument("instinct_id", type=int)
536
+ @click.pass_context
537
+ def cmd_instinct_approve(ctx, instinct_id):
538
+ """Approve a suggested instinct."""
539
+ from .logging_db import approve_instinct, LOG_DB_PATH
540
+ ok = approve_instinct(LOG_DB_PATH, instinct_id)
541
+ click.echo("✓ Approved" if ok else "✗ Not found")
542
+
543
+
544
+ @cli.command("mcp")
545
+ @click.pass_context
546
+ def cmd_mcp(ctx):
547
+ """Run the MCP stdio server (plan/execute/list_skills/get_skill)."""
548
+ from .mcp_server import run_stdio
549
+ run_stdio(ctx.obj.get("cfg"))
550
+
551
+
552
+ @cli.command("browse")
553
+ @click.option("--search", default="", help="Filter by id, name, description, or tag")
554
+ @click.option("--read", "read_id", default=None, help="Print one skill's SKILL.md")
555
+ @click.pass_context
556
+ def cmd_browse(ctx, search, read_id):
557
+ """Browse the skill catalog without leaving the terminal."""
558
+ cfg = ctx.obj.get("cfg")
559
+ idx = read_index(Path(cfg.core.index_path).expanduser())
560
+ if idx is None:
561
+ raise click.ClickException("No index. Run `agent-foundry index` first.")
562
+
563
+ if read_id:
564
+ path = idx.skill_paths.get(read_id)
565
+ if not path or path.startswith("__virtual__:"):
566
+ raise click.ClickException(f"Unknown skill: {read_id}")
567
+ click.echo(Path(path).read_text(encoding="utf-8"))
568
+ return
569
+
570
+ q = search.lower().strip()
571
+ shown = 0
572
+ for s in idx.skills:
573
+ if s.is_fallback:
574
+ continue
575
+ haystack = " ".join([s.id, s.name, s.description, " ".join(s.tags)]).lower()
576
+ if q and q not in haystack:
577
+ continue
578
+ click.echo(f"{s.id:<28} [{s.category:<8}] {s.description[:90]}")
579
+ shown += 1
580
+ click.echo(f"\n{shown} skill(s). Use --read <id> to print one.")
581
+
582
+
583
+ @cli.command("eval")
584
+ @click.option("--tasks", "tasks_path", required=True, type=click.Path(exists=True),
585
+ help="JSON task file: [{id,prompt,skill,verify}]")
586
+ @click.option("--model", default=None)
587
+ @click.option("--json", "as_json", is_flag=True, help="Print raw JSON result")
588
+ @click.pass_context
589
+ def cmd_eval(ctx, tasks_path, model, as_json):
590
+ """Run a paired skill eval (with vs without skill) and report lift."""
591
+ from .eval import run_eval
592
+ cfg = ctx.obj.get("cfg")
593
+ result = run_eval(Path(tasks_path), cfg=cfg, model=model, db_path=LOG_DB_PATH)
594
+ if as_json:
595
+ click.echo(json.dumps(result, indent=2))
596
+ return
597
+ for t in result["tasks"]:
598
+ click.echo(
599
+ f"{t['id']:<30} with={t['with_skill_pass']} without={t['no_skill_pass']}"
600
+ )
601
+ click.echo(
602
+ f"\nscored={result['scored']} with={result['with_skill_pass_rate']} "
603
+ f"without={result['no_skill_pass_rate']} lift={result['skill_lift']} "
604
+ f"cost=${result['total_cost_usd']}"
605
+ )
606
+
607
+
608
+ def main():
609
+ cli(obj={})
610
+
611
+
612
+ if __name__ == "__main__":
613
+ main()