agentskill-toolkit 0.1.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.
agentskill_kit/cli.py ADDED
@@ -0,0 +1,795 @@
1
+ """Command line interface for agentskill-kit (``askit``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Sequence
12
+
13
+ from . import __version__, i18n, ui
14
+ from .config import (CONFIG_KEYS, DEFAULT_REGISTRY, DEFAULT_THRESHOLD, RAW_BASE, config_path, get_setting,
15
+ load_config, load_dotenv, set_setting)
16
+ from .i18n import t
17
+ from .skillfile import SkillError, discover_skills, load_skill, slugify
18
+
19
+ EXPORT_TARGET_CHOICES = None # filled lazily to keep import time low
20
+
21
+
22
+ class CliError(Exception):
23
+ pass
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _rel(path: Any) -> str:
31
+ try:
32
+ return os.path.relpath(str(path))
33
+ except ValueError:
34
+ return str(path)
35
+
36
+
37
+ def _collect(paths: Sequence[str]) -> List[Path]:
38
+ """Resolve CLI path arguments to skill directories."""
39
+ targets = list(paths)
40
+ if not targets:
41
+ targets = ["skills"] if Path("skills").is_dir() else ["."]
42
+ found: List[Path] = []
43
+ for p in targets:
44
+ base = Path(p)
45
+ if not base.exists():
46
+ raise CliError("not found: %s" % p)
47
+ for d in discover_skills(base):
48
+ if d not in found:
49
+ found.append(d)
50
+ if not found:
51
+ raise CliError(t("no_skills", path=", ".join(targets)))
52
+ return found
53
+
54
+
55
+ def _threshold(args: Any) -> float:
56
+ val = getattr(args, "threshold", None)
57
+ if val is None:
58
+ val = get_setting("threshold", DEFAULT_THRESHOLD)
59
+ try:
60
+ return float(val)
61
+ except (TypeError, ValueError):
62
+ return DEFAULT_THRESHOLD
63
+
64
+
65
+ def _add_llm_args(p: argparse.ArgumentParser) -> None:
66
+ p.add_argument("--provider", help="ollama, groq, gemini, openrouter, openai, anthropic, custom or mock")
67
+ p.add_argument("--model", help="model name (defaults depend on the provider)")
68
+ p.add_argument("--base-url", dest="base_url", help="override the API base URL")
69
+
70
+
71
+ def _llm(args: Any):
72
+ from .providers import ProviderError, resolve_provider
73
+ try:
74
+ return resolve_provider(args.provider, args.model, args.base_url)
75
+ except ProviderError as exc:
76
+ raise CliError(str(exc))
77
+
78
+
79
+ def _emit_json(data: Any) -> None:
80
+ print(json.dumps(data, indent=2, ensure_ascii=False))
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # commands
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def cmd_init(args) -> int:
88
+ from .scaffold import create_skill
89
+ tags = [x.strip() for x in (args.tags or "").split(",") if x.strip()]
90
+ try:
91
+ dest = create_skill(args.name, Path(args.dir), args.description or "", args.script, args.author or "",
92
+ tags, args.force)
93
+ except FileExistsError as exc:
94
+ print(ui.fail(t("exists", path=_rel(exc.args[0]))))
95
+ return 1
96
+ print(ui.ok(t("created", path=_rel(dest))))
97
+ print(" edit %s, then run: askit lint %s" % (_rel(dest / "SKILL.md"), _rel(dest)))
98
+ return 0
99
+
100
+
101
+ def cmd_lint(args) -> int:
102
+ from .lint import format_github, lint_skill
103
+ skills = _collect(args.paths)
104
+ results = [lint_skill(p) for p in skills]
105
+ if args.format == "json":
106
+ _emit_json([r.to_dict() for r in results])
107
+ elif args.format == "github":
108
+ for r in results:
109
+ for line in format_github(r):
110
+ print(line)
111
+ else:
112
+ print(ui.heading(t("lint_title")))
113
+ for r in results:
114
+ tone = "green" if not r.errors else "red"
115
+ print("%s %s" % (_rel(r.path), ui.paint("%s %d/100" % (t("score"), r.score), tone)))
116
+ if not r.issues:
117
+ print(" " + ui.ok(t("no_issues")))
118
+ for i in r.issues:
119
+ mark = {"error": ui.paint("error", "red", "bold"), "warning": ui.paint("warn ", "yellow"),
120
+ "info": ui.paint("info ", "gray")}[i.severity]
121
+ loc = ":%d" % i.line if i.line else ""
122
+ print(" %s %s %s%s %s" % (mark, i.code, i.file, loc, i.message))
123
+ if i.hint and i.severity != "info":
124
+ print(" " + ui.paint(i.hint, "dim"))
125
+ errors = sum(len(r.errors) for r in results)
126
+ warnings = sum(len(r.warnings) for r in results)
127
+ if args.format == "text":
128
+ print(t("summary_lint", n=len(results), e=errors, w=warnings))
129
+ return 1 if errors or (args.strict and warnings) else 0
130
+
131
+
132
+ def cmd_scan(args) -> int:
133
+ from .scan import exceeds, scan_skill
134
+ skills = _collect(args.paths)
135
+ results = [scan_skill(p) for p in skills]
136
+ if args.format == "json":
137
+ _emit_json([r.to_dict() for r in results])
138
+ else:
139
+ print(ui.heading(t("scan_title")))
140
+ for r in results:
141
+ tone = {"clean": "green", "review": "yellow", "risky": "red"}[r.verdict]
142
+ print("%s %s" % (_rel(r.path), ui.paint("%s, safety %d/100" % (r.verdict, r.safety), tone)))
143
+ if not r.findings:
144
+ print(" " + ui.ok(t("no_findings")))
145
+ for f in r.findings:
146
+ sev = {"high": ui.paint("high ", "red", "bold"), "medium": ui.paint("medium", "yellow"),
147
+ "low": ui.paint("low ", "gray")}[f.severity]
148
+ loc = ":%d" % f.line if f.line else ""
149
+ print(" %s %s %s%s %s" % (sev, f.rule, f.file, loc, f.message))
150
+ if f.snippet:
151
+ print(" " + ui.paint(ui.truncate(f.snippet, 90), "dim"))
152
+ print(t("summary_scan", n=len(results), h=sum(r.count("high") for r in results),
153
+ m=sum(r.count("medium") for r in results), l=sum(r.count("low") for r in results)))
154
+ return 1 if any(exceeds(r, args.fail_on) for r in results) else 0
155
+
156
+
157
+ def cmd_test(args) -> int:
158
+ from .runner import run_skill_tests
159
+ skills = _collect(args.paths)
160
+ reports = [run_skill_tests(p, timeout=args.timeout, allow_unsafe=args.allow_unsafe) for p in skills]
161
+ if args.format == "json":
162
+ _emit_json([r.to_dict() for r in reports])
163
+ else:
164
+ print(ui.heading(t("test_title")))
165
+ for p, r in zip(skills, reports):
166
+ if r.refused:
167
+ print("%s %s" % (_rel(p), ui.paint("refused", "red")))
168
+ print(" " + ui.fail(r.refused))
169
+ continue
170
+ if not r.has_tests:
171
+ print("%s %s" % (_rel(p), ui.paint(t("no_tests"), "gray")))
172
+ continue
173
+ tone = "green" if r.passed == r.total else "red"
174
+ print("%s %s" % (_rel(p), ui.paint("%d/%d" % (r.passed, r.total), tone)))
175
+ for c in r.cases:
176
+ line = "%s [%s]" % (c.name, c.kind)
177
+ print(" " + (ui.ok(line) if c.passed else ui.fail("%s: %s" % (line, c.detail))))
178
+ tested = [r for r in reports if r.has_tests]
179
+ print(t("summary_test", p=sum(r.passed for r in tested), t=sum(r.total for r in tested), n=len(tested)))
180
+ bad = any(r.refused or (r.has_tests and r.passed < r.total) for r in reports)
181
+ return 1 if bad else 0
182
+
183
+
184
+ def cmd_score(args) -> int:
185
+ from .publish import check_gate
186
+ from .scoring import evaluate_skill
187
+ skills = _collect(args.paths)
188
+ thr = _threshold(args)
189
+ qualities = [evaluate_skill(p, run_tests=not args.no_tests) for p in skills]
190
+ if args.format == "json":
191
+ _emit_json([q.to_dict() for q in qualities])
192
+ else:
193
+ rows = []
194
+ for p, q in zip(skills, qualities):
195
+ tests = ("%d/%d" % (q.tests.passed, q.tests.total)) if q.tests and q.tests.has_tests else "-"
196
+ reasons = check_gate(q, thr)
197
+ rows.append((_rel(p), "%.2f" % q.score, q.grade, str(len(q.lint.errors)) + "/" + str(len(q.lint.warnings)),
198
+ str(q.scan.safety) if q.scan else "-", tests,
199
+ ui.paint("pass", "green") if not reasons else ui.paint("fail", "red")))
200
+ print(ui.table(rows, ["skill", "score", "grade", "lint e/w", "safety", "tests", "gate"]))
201
+ print(t("threshold_note", threshold=thr))
202
+ return 1 if any(check_gate(q, thr) for q in qualities) else 0
203
+
204
+
205
+ def cmd_forge(args) -> int:
206
+ from .forge import ForgeError, forge
207
+ task = args.task or ""
208
+ context = ""
209
+ if args.from_file:
210
+ fp = Path(args.from_file)
211
+ if not fp.is_file():
212
+ raise CliError("not found: %s" % args.from_file)
213
+ context = fp.read_text(encoding="utf-8", errors="replace")
214
+ if not task:
215
+ task = "Turn the source material into a reusable skill."
216
+ if not task.strip():
217
+ raise CliError("give a task: askit forge --task \"summarize meeting notes into action items\"")
218
+ llm = _llm(args)
219
+ print(ui.info(t("forge_start", provider=llm.name, model=llm.model)))
220
+ if not llm.is_free:
221
+ print(ui.warn("this provider is paid; usage is billed to your account"))
222
+ out = args.out or get_setting("output_dir") or "skills"
223
+ thr = _threshold(args)
224
+ rounds = args.rounds or int(get_setting("max_rounds", 3))
225
+ try:
226
+ res = forge(task, llm, out, thr, rounds, args.force, args.dry_run, context, args.name or "",
227
+ not args.no_check, log=lambda m: print(ui.info(m)))
228
+ except ForgeError as exc:
229
+ raise CliError(str(exc))
230
+ q = res.quality
231
+ if q:
232
+ print("%s: score %.2f (%s) after %d round(s)" % (res.name, q.score, q.grade, res.rounds))
233
+ if res.written:
234
+ if not res.accepted:
235
+ print(ui.warn(t("forge_below", score="%.2f" % (q.score if q else 0), threshold=thr)))
236
+ print(ui.ok(t("forge_done", path=_rel(res.path), score="%.2f" % (q.score if q else 0))))
237
+ print(" next: askit test %s askit export %s --target claude" % (_rel(res.path), _rel(res.path)))
238
+ return 0
239
+ if res.accepted and args.dry_run:
240
+ print(ui.ok("dry run: skill would be written to %s" % _rel(Path(out) / res.name)))
241
+ if args.show:
242
+ for path, content in res.files.items():
243
+ print("\n--- %s ---\n%s" % (path, content))
244
+ return 0
245
+ print(ui.fail(t("forge_rejected", score="%.2f" % (q.score if q else 0), threshold=thr)))
246
+ return 1
247
+
248
+
249
+ def cmd_eval(args) -> int:
250
+ from .evalrun import run_evals
251
+ skills = _collect([args.path])
252
+ llm = _llm(args)
253
+ rc = 0
254
+ for p in skills:
255
+ rep = run_evals(p, llm, args.baseline, args.limit)
256
+ if not rep.has_evals:
257
+ print("%s %s" % (_rel(p), ui.paint("no evals/evals.json", "gray")))
258
+ continue
259
+ base = rep.baseline_passed
260
+ extra = "" if base is None else " without skill: %d/%d" % (base, len(rep.cases))
261
+ print("%s %d/%d with skill%s" % (_rel(p), rep.passed, len(rep.cases), extra))
262
+ for c in rep.cases:
263
+ line = c.name + ("" if c.baseline_passed is None else " (baseline %s)" % ("pass" if c.baseline_passed else "fail"))
264
+ print(" " + (ui.ok(line) if c.passed else ui.fail("%s: %s" % (line, c.detail))))
265
+ if rep.passed < len(rep.cases):
266
+ rc = 1
267
+ return rc
268
+
269
+
270
+ def cmd_mine(args) -> int:
271
+ from . import mine
272
+ files = [Path(f) for f in args.file] if args.file else None
273
+ if args.stdin:
274
+ cmds = mine.parse_history(sys.stdin.read())
275
+ else:
276
+ if not files and not mine.default_history_files():
277
+ raise CliError("no shell history found. Pass one with --file, or pipe it with --stdin")
278
+ cmds = mine.read_history(files)
279
+ cands = mine.find_candidates(cmds, args.min_count, args.max_len, args.limit)
280
+ if args.format == "json":
281
+ _emit_json([{"name": c.name, "kind": c.kind, "count": c.count, "steps": c.template,
282
+ "examples": c.examples, "score": round(c.score, 2)} for c in cands])
283
+ return 0
284
+ print(ui.info("read %d commands" % len(cmds)))
285
+ if not cands:
286
+ print(t("mine_none"))
287
+ return 0
288
+ print(t("mine_found", n=len(cands)))
289
+ for i, c in enumerate(cands, 1):
290
+ print("\n%s %s (%dx)" % (ui.paint("#%d" % i, "bold"), ui.paint(c.name, "cyan"), c.count))
291
+ for step in c.template:
292
+ print(" " + step)
293
+ if args.draft or args.forge:
294
+ n = args.draft or args.forge
295
+ if not 1 <= n <= len(cands):
296
+ raise CliError("pick a number between 1 and %d" % len(cands))
297
+ cand = cands[n - 1]
298
+ out = Path(args.out or "skills")
299
+ if args.draft:
300
+ files_map = mine.draft_skill_files(cand)
301
+ dest = out / slugify(cand.name)
302
+ if dest.exists() and not args.force:
303
+ raise CliError(t("exists", path=_rel(dest)))
304
+ dest.mkdir(parents=True, exist_ok=True)
305
+ (dest / "SKILL.md").write_text(files_map["SKILL.md"], encoding="utf-8")
306
+ print("\n" + ui.ok(t("created", path=_rel(dest))))
307
+ else:
308
+ from .forge import ForgeError, forge
309
+ llm = _llm(args)
310
+ task = ("Create a skill that automates this recurring workflow found in shell history (%d times). "
311
+ "Steps with placeholders:\n%s\nExample runs:\n%s" % (
312
+ cand.count, "\n".join(cand.template), "\n".join(cand.examples)))
313
+ try:
314
+ res = forge(task, llm, out, _threshold(args), 3, args.force, False, "", cand.name,
315
+ True, log=lambda m: print(ui.info(m)))
316
+ except ForgeError as exc:
317
+ raise CliError(str(exc))
318
+ print(ui.ok(t("forge_done", path=_rel(res.path), score="%.2f" % res.quality.score)) if res.written
319
+ else ui.fail(t("forge_rejected", score="%.2f" % (res.quality.score if res.quality else 0),
320
+ threshold=_threshold(args))))
321
+ else:
322
+ print("\nWrite one as a skill: askit mine --draft 1 (no model needed)")
323
+ print("Or let a model refine: askit mine --forge 1")
324
+ return 0
325
+
326
+
327
+ def cmd_export(args) -> int:
328
+ from .export import ExportError, TARGETS, export_skill
329
+ skills = _collect([args.path])
330
+ targets = list(TARGETS) if args.target == "all" else [args.target]
331
+ if args.global_ and args.target == "all":
332
+ targets = [n for n, tg in TARGETS.items() if tg.global_]
333
+ base = Path(args.dest) if args.dest else None
334
+ for sk in skills:
335
+ name = load_skill(sk).name
336
+ for tg in targets:
337
+ try:
338
+ actions = export_skill(sk, tg, base, args.global_, args.dry_run)
339
+ except ExportError as exc:
340
+ raise CliError(str(exc))
341
+ print(ui.ok(t("exported", name=name, target=tg)) + (" (dry run)" if args.dry_run else ""))
342
+ for a in actions:
343
+ print(" " + a)
344
+ return 0
345
+
346
+
347
+ def cmd_pack(args) -> int:
348
+ from .publish import pack
349
+ for sk in _collect([args.path]):
350
+ print(ui.ok(t("packed", path=_rel(pack(sk, args.out)))))
351
+ return 0
352
+
353
+
354
+ def cmd_publish(args) -> int:
355
+ from .publish import PublishError, check_gate, pack, pr_commands, publish_to_registry, run_pr
356
+ from .scoring import evaluate_skill
357
+ skills = _collect([args.path])
358
+ thr = _threshold(args)
359
+ rc = 0
360
+ for sk in skills:
361
+ name = load_skill(sk).name
362
+ q = evaluate_skill(sk)
363
+ reasons = check_gate(q, thr)
364
+ print("%s: score %.2f (%s)" % (name, q.score, q.grade))
365
+ if reasons and not args.force:
366
+ print(ui.fail(t("gate_failed", reason="; ".join(reasons))))
367
+ rc = 1
368
+ continue
369
+ if args.registry_dir:
370
+ try:
371
+ info = publish_to_registry(sk, args.registry_dir, thr, args.force, args.dry_run)
372
+ except PublishError as exc:
373
+ raise CliError(str(exc))
374
+ for step in info["steps"]:
375
+ print(" " + step)
376
+ if args.open_pr and not args.dry_run:
377
+ ok_, msg = run_pr(name, args.registry_dir)
378
+ print(ui.ok(msg) if ok_ else ui.fail(msg))
379
+ rc = rc or (0 if ok_ else 1)
380
+ else:
381
+ print("\nOpen a pull request from the registry checkout:")
382
+ for c in pr_commands(name):
383
+ print(" " + c)
384
+ else:
385
+ archive = pack(sk, args.out)
386
+ print(ui.ok(t("published", name=name)))
387
+ print(ui.ok(t("packed", path=_rel(archive))))
388
+ print("\nShare it:")
389
+ print(" - Push the folder to a GitHub repo, then others run: askit install github:OWNER/REPO/%s" % _rel(sk).replace("\\", "/"))
390
+ print(" - Or add it to the community registry: fork https://github.com/Lethe044/agentskill-kit,")
391
+ print(" then run: askit publish %s --registry-dir PATH_TO_YOUR_FORK" % _rel(sk))
392
+ return rc
393
+
394
+
395
+ def _registry_source(args) -> str:
396
+ return getattr(args, "registry", None) or get_setting("registry") or DEFAULT_REGISTRY
397
+
398
+
399
+ def cmd_search(args) -> int:
400
+ from . import registry
401
+ try:
402
+ index = registry.load_index(_registry_source(args))
403
+ except SkillError as exc:
404
+ raise CliError(str(exc))
405
+ hits = registry.search(index, " ".join(args.query), args.tag or "", args.limit)
406
+ if args.format == "json":
407
+ _emit_json(hits)
408
+ return 0
409
+ if not hits:
410
+ print(t("no_results"))
411
+ return 1
412
+ rows = [(s["name"], "%s %.2f" % (s.get("grade", "?"), s.get("score", 0)),
413
+ "yes" if s.get("tested") else "no", ui.truncate(s.get("description", ""), 70)) for s in hits]
414
+ print(ui.table(rows, ["name", "quality", "tested", "description"]))
415
+ print("\ninstall: askit install %s" % hits[0]["name"])
416
+ return 0
417
+
418
+
419
+ def cmd_install(args) -> int:
420
+ from .install import InstallError, install
421
+ base = Path(args.dest) if args.dest else None
422
+ try:
423
+ res = install(args.source, args.target, base, args.global_, args.registry, args.force, args.dry_run)
424
+ except InstallError as exc:
425
+ raise CliError(str(exc))
426
+ print(ui.ok(t("installed", name=res["name"], target=args.target)) + (" (dry run)" if args.dry_run else ""))
427
+ for a in res["actions"]:
428
+ print(" " + a)
429
+ for n in res["notes"]:
430
+ print(ui.warn(n))
431
+ return 0
432
+
433
+
434
+ def cmd_uninstall(args) -> int:
435
+ from .install import InstallError, uninstall
436
+ base = Path(args.dest) if args.dest else None
437
+ try:
438
+ done = uninstall(args.name, args.target, base, args.global_)
439
+ except InstallError as exc:
440
+ raise CliError(str(exc))
441
+ if not done:
442
+ print(ui.warn("%s was not found for target %s" % (args.name, args.target)))
443
+ return 1
444
+ print(ui.ok(t("uninstalled", name=args.name)))
445
+ for d in done:
446
+ print(" " + d)
447
+ return 0
448
+
449
+
450
+ def cmd_list(args) -> int:
451
+ if args.installed:
452
+ from .install import load_ledger
453
+ rows = [(e["name"], e.get("version", ""), e["target"], "global" if e.get("global") else "project",
454
+ ui.truncate(e.get("source", ""), 50)) for e in load_ledger()["installed"]]
455
+ if not rows:
456
+ print("nothing installed through askit yet")
457
+ return 0
458
+ print(ui.table(rows, ["name", "version", "target", "scope", "source"]))
459
+ return 0
460
+ rows = []
461
+ for p in _collect(args.paths):
462
+ sk = load_skill(p)
463
+ rows.append((sk.name, sk.version, _rel(p), ui.truncate(sk.description, 60)))
464
+ print(ui.table(rows, ["name", "version", "path", "description"]))
465
+ return 0
466
+
467
+
468
+ def cmd_registry(args) -> int:
469
+ from . import registry
470
+ from .site import write_site
471
+ roots = args.roots or (["skills"] if Path("skills").is_dir() else ["."])
472
+ for r in roots:
473
+ if not Path(r).exists():
474
+ raise CliError("not found: %s" % r)
475
+ base = Path(args.base).resolve() if getattr(args, "base", None) else Path.cwd()
476
+ if args.reg_cmd == "build":
477
+ index = registry.build_index(roots, base_url=args.base_url, include_body=not args.no_body,
478
+ relative_to=base, run_tests=not args.no_tests)
479
+ out = Path(args.out)
480
+ if args.check:
481
+ if registry.index_is_current(index, out):
482
+ print(ui.ok("%s is up to date (%d skills)" % (_rel(out), index["count"])))
483
+ return 0
484
+ print(ui.fail("%s is out of date. Run: askit registry build" % _rel(out)))
485
+ return 1
486
+ registry.write_index(index, out)
487
+ print(ui.ok("wrote %s (%d skills)" % (_rel(out), index["count"])))
488
+ return 0
489
+ index = registry.build_index(roots, base_url=args.base_url, include_body=True, relative_to=base,
490
+ run_tests=not args.no_tests)
491
+ page = write_site(index, args.out, args.title)
492
+ print(ui.ok("wrote %s (%d skills)" % (_rel(page), index["count"])))
493
+ return 0
494
+
495
+
496
+ def cmd_serve(args) -> int:
497
+ from .server import default_roots, serve
498
+ roots = [Path(p) for p in args.paths] if args.paths else default_roots()
499
+ if not roots:
500
+ raise CliError("no skills folders found. Pass one: askit serve ./skills")
501
+ serve(roots, args.host, args.port, args.open, lambda url: print(ui.ok(t("serving", url=url))))
502
+ return 0
503
+
504
+
505
+ def cmd_providers(args) -> int:
506
+ from .providers import PROVIDERS, ProviderError, provider_status, resolve_provider
507
+ if args.models:
508
+ try:
509
+ llm = resolve_provider(args.provider, None, None)
510
+ for m in llm.list_models():
511
+ print(m)
512
+ except (ProviderError, OSError) as exc:
513
+ raise CliError(str(exc))
514
+ return 0
515
+ rows = []
516
+ for r in provider_status():
517
+ cost = "free" if r["free"] else "paid"
518
+ setup = ("set " + r["key_env"]) if r["key_env"] and r["state"] == "no key" else ""
519
+ rows.append((r["name"], cost, r["state"], r["model"] or "(auto)", setup))
520
+ print(ui.table(rows, ["provider", "cost", "status", "default model", "setup"]))
521
+ print("\nFree options: ollama (local), groq, gemini, openrouter. Paid keys are optional.")
522
+ print("Pick one with --provider NAME or: askit config set provider NAME")
523
+ for name in ("groq", "gemini", "openrouter"):
524
+ spec = PROVIDERS[name]
525
+ print(" %-10s key: %s" % (name, spec.signup))
526
+ return 0
527
+
528
+
529
+ def cmd_config(args) -> int:
530
+ if args.action == "show":
531
+ cfg = load_config()
532
+ print("file: %s" % config_path())
533
+ for k, desc in CONFIG_KEYS.items():
534
+ print(" %-11s %-18s %s" % (k, cfg.get(k, "(not set)"), ui.paint(desc, "dim")))
535
+ return 0
536
+ if args.action == "path":
537
+ print(config_path())
538
+ return 0
539
+ if not args.key or args.key not in CONFIG_KEYS:
540
+ raise CliError("choose a key: " + ", ".join(CONFIG_KEYS))
541
+ if args.action == "get":
542
+ print(get_setting(args.key, ""))
543
+ elif args.action == "set":
544
+ if args.value is None:
545
+ raise CliError("usage: askit config set KEY VALUE")
546
+ value: Any = args.value
547
+ if args.key in ("threshold",):
548
+ value = float(value)
549
+ if args.key in ("max_rounds",):
550
+ value = int(value)
551
+ set_setting(args.key, value)
552
+ print(ui.ok("%s = %s" % (args.key, value)))
553
+ else:
554
+ set_setting(args.key, None)
555
+ print(ui.ok("%s removed" % args.key))
556
+ return 0
557
+
558
+
559
+ def cmd_doctor(args) -> int:
560
+ from . import mine
561
+ from .providers import provider_status
562
+ print(ui.heading(t("doctor_title")))
563
+ problems = 0
564
+
565
+ def row(good: bool, msg: str, bad_is_problem: bool = True) -> None:
566
+ nonlocal problems
567
+ print(" " + (ui.ok(msg) if good else (ui.fail(msg) if bad_is_problem else ui.warn(msg))))
568
+ if not good and bad_is_problem:
569
+ problems += 1
570
+
571
+ row(sys.version_info >= (3, 9), "Python %s" % sys.version.split()[0])
572
+ row(shutil.which("git") is not None, "git %s" % ("found" if shutil.which("git") else "not found"), False)
573
+ row(shutil.which("gh") is not None, "GitHub CLI %s (only needed for publish --open-pr)" % (
574
+ "found" if shutil.which("gh") else "not found"), False)
575
+ try:
576
+ from .config import home_dir
577
+ h = home_dir()
578
+ row(os.access(h, os.W_OK), "data directory %s" % h)
579
+ except OSError as exc:
580
+ row(False, "data directory: %s" % exc)
581
+ usable = [r for r in provider_status() if r["state"] in ("key found", "running", "configured")
582
+ or r["state"].startswith("running")]
583
+ row(bool(usable), "LLM providers ready: %s" % (", ".join(r["name"] for r in usable) or "none (try: askit providers)"), False)
584
+ hist = mine.default_history_files()
585
+ row(bool(hist), "shell history files: %d found" % len(hist), False)
586
+ if args.online:
587
+ from . import registry
588
+ src = _registry_source(args)
589
+ try:
590
+ idx = registry.load_index(src)
591
+ row(True, "registry reachable (%d skills)" % idx.get("count", len(idx.get("skills", []))))
592
+ except SkillError as exc:
593
+ row(False, str(exc))
594
+ print("\nagentskill-kit %s" % __version__)
595
+ return 1 if problems else 0
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # parser
600
+ # ---------------------------------------------------------------------------
601
+
602
+ def build_parser() -> argparse.ArgumentParser:
603
+ from .export import TARGETS
604
+
605
+ p = argparse.ArgumentParser(
606
+ prog="askit", description="Create, lint, scan, test, share and export agent skills.",
607
+ epilog="Docs: https://github.com/Lethe044/agentskill-kit")
608
+ p.add_argument("--version", action="version", version="agentskill-kit %s" % __version__)
609
+ p.add_argument("--lang", choices=["en", "tr"], help="interface language")
610
+ p.add_argument("--no-color", action="store_true", help="disable coloured output")
611
+ sub = p.add_subparsers(dest="command", metavar="COMMAND")
612
+
613
+ def add(name: str, func, help_: str) -> argparse.ArgumentParser:
614
+ sp = sub.add_parser(name, help=help_, description=help_)
615
+ sp.set_defaults(func=func)
616
+ return sp
617
+
618
+ sp = add("init", cmd_init, "create a new skill from a template")
619
+ sp.add_argument("name")
620
+ sp.add_argument("--dir", default="skills", help="parent directory (default: skills)")
621
+ sp.add_argument("--description", "-d")
622
+ sp.add_argument("--script", action="store_true", help="include a Python script and tests")
623
+ sp.add_argument("--author")
624
+ sp.add_argument("--tags", help="comma separated tags")
625
+ sp.add_argument("--force", action="store_true")
626
+
627
+ sp = add("lint", cmd_lint, "check SKILL.md structure (offline, no model)")
628
+ sp.add_argument("paths", nargs="*")
629
+ sp.add_argument("--strict", action="store_true", help="treat warnings as errors")
630
+ sp.add_argument("--format", choices=["text", "json", "github"], default="text")
631
+
632
+ sp = add("scan", cmd_scan, "scan skills for security red flags")
633
+ sp.add_argument("paths", nargs="*")
634
+ sp.add_argument("--fail-on", choices=["high", "medium", "low", "never"], default="high")
635
+ sp.add_argument("--format", choices=["text", "json"], default="text")
636
+
637
+ sp = add("test", cmd_test, "run the tests that ship with a skill")
638
+ sp.add_argument("paths", nargs="*")
639
+ sp.add_argument("--timeout", type=float, default=20.0, help="seconds per case")
640
+ sp.add_argument("--allow-unsafe", action="store_true", help="run code even if the scanner flags it")
641
+ sp.add_argument("--format", choices=["text", "json"], default="text")
642
+
643
+ sp = add("score", cmd_score, "combine lint, scan and tests into a quality score")
644
+ sp.add_argument("paths", nargs="*")
645
+ sp.add_argument("--threshold", type=float)
646
+ sp.add_argument("--no-tests", action="store_true")
647
+ sp.add_argument("--format", choices=["text", "json"], default="text")
648
+
649
+ sp = add("forge", cmd_forge, "generate a tested skill from a task description")
650
+ sp.add_argument("--task", "-t", help="what the skill should do")
651
+ sp.add_argument("--from-file", help="notes, transcript or runbook to learn from")
652
+ sp.add_argument("--name", help="preferred skill name")
653
+ sp.add_argument("--out", help="output folder (default: skills)")
654
+ sp.add_argument("--threshold", type=float, help="minimum score to accept (default 0.8)")
655
+ sp.add_argument("--rounds", type=int, help="maximum refinement rounds (default 3)")
656
+ sp.add_argument("--force", action="store_true", help="write the best attempt even if it misses the threshold")
657
+ sp.add_argument("--dry-run", action="store_true")
658
+ sp.add_argument("--show", action="store_true", help="print the files on a dry run")
659
+ sp.add_argument("--no-check", action="store_true", help="skip the duplicate check")
660
+ _add_llm_args(sp)
661
+
662
+ sp = add("eval", cmd_eval, "check how well a model follows a skill (evals/evals.json)")
663
+ sp.add_argument("path")
664
+ sp.add_argument("--baseline", action="store_true", help="also run without the skill for comparison")
665
+ sp.add_argument("--limit", type=int, default=0)
666
+ _add_llm_args(sp)
667
+
668
+ sp = add("mine", cmd_mine, "find repeated workflows in shell history")
669
+ sp.add_argument("--file", action="append", help="history file (repeatable)")
670
+ sp.add_argument("--stdin", action="store_true", help="read history from standard input")
671
+ sp.add_argument("--min-count", type=int, default=3)
672
+ sp.add_argument("--max-len", type=int, default=4)
673
+ sp.add_argument("--limit", type=int, default=10)
674
+ sp.add_argument("--draft", type=int, metavar="N", help="write candidate N as a skill (no model)")
675
+ sp.add_argument("--forge", type=int, metavar="N", help="refine candidate N with a model")
676
+ sp.add_argument("--out", help="output folder (default: skills)")
677
+ sp.add_argument("--force", action="store_true")
678
+ sp.add_argument("--threshold", type=float)
679
+ sp.add_argument("--format", choices=["text", "json"], default="text")
680
+ _add_llm_args(sp)
681
+
682
+ sp = add("export", cmd_export, "convert and install a skill for another agent")
683
+ sp.add_argument("path")
684
+ sp.add_argument("--target", "-T", default="claude", choices=list(TARGETS) + ["all"])
685
+ sp.add_argument("--dest", help="project folder (default: current directory)")
686
+ sp.add_argument("--global", dest="global_", action="store_true", help="install in your home directory")
687
+ sp.add_argument("--dry-run", action="store_true")
688
+
689
+ sp = add("pack", cmd_pack, "bundle a skill into a zip with checksums")
690
+ sp.add_argument("path")
691
+ sp.add_argument("--out", default="dist")
692
+
693
+ sp = add("publish", cmd_publish, "check a skill against the quality gate and prepare it for sharing")
694
+ sp.add_argument("path")
695
+ sp.add_argument("--registry-dir", help="path to a checkout of a registry repository")
696
+ sp.add_argument("--open-pr", action="store_true", help="create the pull request with git and gh")
697
+ sp.add_argument("--threshold", type=float)
698
+ sp.add_argument("--force", action="store_true")
699
+ sp.add_argument("--dry-run", action="store_true")
700
+ sp.add_argument("--out", default="dist")
701
+
702
+ sp = add("search", cmd_search, "search the skill registry")
703
+ sp.add_argument("query", nargs="*")
704
+ sp.add_argument("--tag")
705
+ sp.add_argument("--limit", type=int, default=15)
706
+ sp.add_argument("--registry", help="registry index URL or file")
707
+ sp.add_argument("--format", choices=["text", "json"], default="text")
708
+
709
+ sp = add("install", cmd_install, "install a skill from the registry, GitHub or a folder")
710
+ sp.add_argument("source", help="registry name, ./folder or github:owner/repo/path[@ref]")
711
+ sp.add_argument("--target", "-T", default="claude", choices=list(TARGETS))
712
+ sp.add_argument("--dest", help="project folder (default: current directory)")
713
+ sp.add_argument("--global", dest="global_", action="store_true")
714
+ sp.add_argument("--registry", help="registry index URL or file")
715
+ sp.add_argument("--force", action="store_true", help="install even if lint or scan fail")
716
+ sp.add_argument("--dry-run", action="store_true")
717
+
718
+ sp = add("uninstall", cmd_uninstall, "remove an installed skill")
719
+ sp.add_argument("name")
720
+ sp.add_argument("--target", "-T", default="claude", choices=list(TARGETS))
721
+ sp.add_argument("--dest")
722
+ sp.add_argument("--global", dest="global_", action="store_true")
723
+
724
+ sp = add("list", cmd_list, "list local skills or what askit installed")
725
+ sp.add_argument("paths", nargs="*")
726
+ sp.add_argument("--installed", action="store_true")
727
+
728
+ sp = add("registry", cmd_registry, "build a registry index or a static website")
729
+ rsub = sp.add_subparsers(dest="reg_cmd", metavar="build|site", required=True)
730
+ rb = rsub.add_parser("build", help="write registry/index.json")
731
+ rb.add_argument("roots", nargs="*")
732
+ rb.add_argument("--out", default="registry/index.json")
733
+ rb.add_argument("--base-url", default=RAW_BASE)
734
+ rb.add_argument("--base", help="folder the skill paths are relative to (default: current directory)")
735
+ rb.add_argument("--check", action="store_true", help="exit 1 if the index is out of date")
736
+ rb.add_argument("--no-body", action="store_true")
737
+ rb.add_argument("--no-tests", action="store_true", help="skip running tests while scoring")
738
+ rs = rsub.add_parser("site", help="write a searchable static website")
739
+ rs.add_argument("roots", nargs="*")
740
+ rs.add_argument("--out", default="site")
741
+ rs.add_argument("--title", default="agentskill-kit skill registry")
742
+ rs.add_argument("--base-url", default=RAW_BASE)
743
+ rs.add_argument("--base")
744
+ rs.add_argument("--no-tests", action="store_true")
745
+ sp.set_defaults(func=cmd_registry)
746
+
747
+ sp = add("serve", cmd_serve, "browse local skills in your browser")
748
+ sp.add_argument("paths", nargs="*")
749
+ sp.add_argument("--host", default="127.0.0.1")
750
+ sp.add_argument("--port", type=int, default=8765)
751
+ sp.add_argument("--open", action="store_true", help="open the browser")
752
+
753
+ sp = add("providers", cmd_providers, "show LLM providers and what is ready")
754
+ sp.add_argument("--models", action="store_true", help="list models of --provider")
755
+ sp.add_argument("--provider")
756
+
757
+ sp = add("config", cmd_config, "show or change default settings")
758
+ sp.add_argument("action", choices=["show", "get", "set", "unset", "path"])
759
+ sp.add_argument("key", nargs="?")
760
+ sp.add_argument("value", nargs="?")
761
+
762
+ sp = add("doctor", cmd_doctor, "check your environment")
763
+ sp.add_argument("--online", action="store_true", help="also check the registry")
764
+ sp.add_argument("--registry")
765
+ return p
766
+
767
+
768
+ def main(argv: Optional[Sequence[str]] = None) -> int:
769
+ ui.setup_stdio()
770
+ load_dotenv()
771
+ parser = build_parser()
772
+ args = parser.parse_args(argv)
773
+ if args.no_color:
774
+ ui.set_no_color(True)
775
+ i18n.set_lang(args.lang or get_setting("lang") or i18n.detect_lang())
776
+ if not getattr(args, "command", None):
777
+ parser.print_help()
778
+ return 0
779
+ try:
780
+ return int(args.func(args) or 0)
781
+ except CliError as exc:
782
+ print(ui.fail(str(exc)), file=sys.stderr)
783
+ return 1
784
+ except SkillError as exc:
785
+ print(ui.fail(str(exc)), file=sys.stderr)
786
+ return 1
787
+ except KeyboardInterrupt:
788
+ print("", file=sys.stderr)
789
+ return 130
790
+ except BrokenPipeError:
791
+ return 0
792
+
793
+
794
+ if __name__ == "__main__":
795
+ sys.exit(main())