@heytherevibin/skillforge 0.10.1 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +37 -345
  4. package/RELEASING.md +7 -6
  5. package/STRATEGY.md +2 -2
  6. package/bin/cli.js +297 -52
  7. package/ci/test-user-env-profile.cjs +65 -0
  8. package/docs/README.md +14 -0
  9. package/docs/architecture-and-data.md +90 -0
  10. package/docs/cli-reference.md +57 -0
  11. package/docs/environment-and-configuration.md +76 -0
  12. package/docs/getting-started.md +88 -0
  13. package/docs/mcp-integration.md +75 -0
  14. package/docs/troubleshooting.md +50 -0
  15. package/lib/templates/claude-code-skillforge-global.md +3 -3
  16. package/lib/templates/cursor-skillforge-global.md +6 -2
  17. package/lib/user-env-profile.js +141 -0
  18. package/package.json +3 -2
  19. package/python/app/agent_cli.py +334 -0
  20. package/python/app/explain_route.py +170 -0
  21. package/python/app/health_cli.py +13 -0
  22. package/python/app/main.py +131 -48
  23. package/python/app/materialize.py +150 -68
  24. package/python/app/mcp_contract.py +2 -1
  25. package/python/app/mcp_operator.py +252 -0
  26. package/python/app/mcp_server.py +290 -118
  27. package/python/app/npm_pkg_version.py +38 -0
  28. package/python/app/pick_diversify.py +51 -0
  29. package/python/app/replay_cli.py +145 -0
  30. package/python/app/route_cli.py +251 -87
  31. package/python/app/route_cli_pick.py +35 -0
  32. package/python/app/route_policies.py +18 -3
  33. package/python/app/route_quality.py +70 -1
  34. package/python/app/router_llm.py +85 -0
  35. package/python/app/router_mode.py +21 -0
  36. package/python/app/routing_signals.py +7 -1
  37. package/python/app/skill_manifest.py +67 -0
  38. package/python/app/skills_author_cli.py +117 -0
  39. package/python/app/tips_cli.py +37 -0
  40. package/python/app/tools_cli.py +276 -0
  41. package/python/fixtures/route_eval/smoke.json +5 -0
  42. package/python/requirements.txt +1 -0
  43. package/python/tests/test_capabilities_bundle.py +33 -0
  44. package/python/tests/test_materialize_hosts.py +108 -0
  45. package/python/tests/test_mcp_contract.py +1 -1
  46. package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
  47. package/python/tests/test_mcp_operator.py +84 -0
  48. package/python/tests/test_npm_pkg_version.py +21 -0
  49. package/python/tests/test_pick_diversify.py +47 -0
  50. package/python/tests/test_replay_cli.py +31 -0
  51. package/python/tests/test_route_cli_pick.py +25 -0
  52. package/python/tests/test_route_policies.py +29 -0
  53. package/python/tests/test_route_quality.py +72 -0
  54. package/python/tests/test_router_llm.py +63 -0
  55. package/python/tests/test_router_mode_env.py +21 -0
  56. package/python/tests/test_routing_signals.py +20 -0
  57. package/python/tests/test_skill_manifest.py +48 -0
  58. package/python/tests/test_tools_cli.py +69 -0
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import math
5
+ import os
5
6
  from typing import Any
6
7
 
7
8
 
@@ -31,6 +32,53 @@ def top1_cosine_vs_routing_agreement(facets: list[dict[str, Any]]) -> bool | Non
31
32
  return top_route == best_cos_name
32
33
 
33
34
 
35
+ def _env_float(name: str, default_str: str) -> float:
36
+ raw = os.getenv(name, default_str).strip()
37
+ try:
38
+ return float(raw)
39
+ except ValueError:
40
+ return float(default_str)
41
+
42
+
43
+ def _compute_ambiguous_and_tier(
44
+ *,
45
+ n: int,
46
+ cosine_margin: float | None,
47
+ routing_score_margin: float | None,
48
+ ) -> tuple[bool, str | None]:
49
+ """Return (ambiguous, confidence_tier)."""
50
+ if n == 0:
51
+ return False, None
52
+ if n == 1:
53
+ return False, "high"
54
+ ambig_off = os.getenv("SKILLFORGE_ROUTE_AMBIGUITY_DISABLE", "").strip().lower() in (
55
+ "1",
56
+ "true",
57
+ "yes",
58
+ )
59
+ if ambig_off:
60
+ ambiguous = False
61
+ else:
62
+ cos_thr = _env_float("SKILLFORGE_ROUTE_AMBIGUITY_COS_MARGIN", "0.012")
63
+ route_thr = _env_float("SKILLFORGE_ROUTE_AMBIGUITY_ROUTE_MARGIN", "0.018")
64
+ ambiguous = False
65
+ if cosine_margin is not None and cosine_margin < cos_thr:
66
+ ambiguous = True
67
+ if routing_score_margin is not None and routing_score_margin < route_thr:
68
+ ambiguous = True
69
+ tier: str
70
+ if ambiguous:
71
+ tier = "low"
72
+ elif cosine_margin is not None and routing_score_margin is not None:
73
+ if cosine_margin >= 0.04 and routing_score_margin >= 0.06:
74
+ tier = "high"
75
+ else:
76
+ tier = "medium"
77
+ else:
78
+ tier = "medium"
79
+ return ambiguous, tier
80
+
81
+
34
82
  def build_route_quality(
35
83
  *,
36
84
  facet_list: list[dict[str, Any]],
@@ -45,6 +93,7 @@ def build_route_quality(
45
93
  host_shortlist_only: bool = False,
46
94
  haiku_rerank_applied: bool = False,
47
95
  pick_path: str,
96
+ pick_diversify: dict[str, Any] | None = None,
48
97
  ) -> dict[str, Any]:
49
98
  """Structured signals for operators and MCP hosts (JSON-serializable)."""
50
99
  n = len(facet_list)
@@ -52,14 +101,24 @@ def build_route_quality(
52
101
  second_cos: float | None = None
53
102
  margin: float | None = None
54
103
  top_routing_score: float | None = None
104
+ second_routing_score: float | None = None
105
+ routing_score_margin: float | None = None
55
106
  if facet_list:
56
107
  top_cos = round(coerce_route_float(facet_list[0].get("cosine_similarity")), 6)
57
108
  top_routing_score = round(coerce_route_float(facet_list[0].get("routing_score")), 6)
58
109
  if len(facet_list) > 1:
59
110
  second_cos = round(coerce_route_float(facet_list[1].get("cosine_similarity")), 6)
60
111
  margin = round(float(top_cos - second_cos), 6)
112
+ second_routing_score = round(coerce_route_float(facet_list[1].get("routing_score")), 6)
113
+ if top_routing_score is not None and second_routing_score is not None:
114
+ routing_score_margin = round(float(top_routing_score - second_routing_score), 6)
61
115
 
62
116
  agree = top1_cosine_vs_routing_agreement(facet_list) if router_hybrid not in ("", "off", None) else None
117
+ ambiguous, confidence_tier = _compute_ambiguous_and_tier(
118
+ n=n,
119
+ cosine_margin=margin,
120
+ routing_score_margin=routing_score_margin,
121
+ )
63
122
 
64
123
  try:
65
124
  prl = int(policy_rules_loaded)
@@ -67,16 +126,25 @@ def build_route_quality(
67
126
  prl = 0
68
127
  prl = max(0, prl)
69
128
 
129
+ div = pick_diversify if isinstance(pick_diversify, dict) else None
130
+ if div is None:
131
+ div = {"applied": False, "dropped": [], "max_per_source": None}
132
+
70
133
  return {
71
- "schema": "route_quality/1",
134
+ "schema": "route_quality/2",
72
135
  "shortlist": {
73
136
  "size": n,
74
137
  "top_cosine_similarity": top_cos,
75
138
  "second_cosine_similarity": second_cos,
76
139
  "cosine_margin": margin,
140
+ "second_routing_score": second_routing_score,
141
+ "routing_score_margin": routing_score_margin,
142
+ "ambiguous": ambiguous,
143
+ "confidence_tier": confidence_tier,
77
144
  "top_routing_score": top_routing_score,
78
145
  "hybrid_mode": router_hybrid or "off",
79
146
  "top1_dense_and_fused_agree": agree,
147
+ "cosine_leader_matches_routing_top": agree,
80
148
  },
81
149
  "router": {
82
150
  "mode": router_mode,
@@ -84,6 +152,7 @@ def build_route_quality(
84
152
  "host_picked": host_picked,
85
153
  "host_shortlist_only": host_shortlist_only,
86
154
  "haiku_rerank_applied": haiku_rerank_applied,
155
+ "pick_diversify": div,
87
156
  },
88
157
  "session": {
89
158
  "rerouted": rerouted,
@@ -0,0 +1,85 @@
1
+ """Pluggable backend for Router rerank + final-pick LLM phases.
2
+
3
+ When ``SKILLFORGE_TRANSPORT=mcp`` (stdio MCP only), only Anthropic-backed clients
4
+ are wired from ``build_router_and_skills`` (legacy semantics).
5
+
6
+ Standalone CLI may set ``SKILLFORGE_ROUTER_LLM_BACKEND=openai_compatible`` targeting
7
+ ``OPENAI_API_BASE`` (e.g. Ollama ``http://localhost:11434/v1``).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Protocol
13
+
14
+ from anthropic import AsyncAnthropic
15
+
16
+
17
+ class RouterLLM(Protocol):
18
+ backend_name: str
19
+
20
+ async def complete(self, *, system: str, user: str, max_tokens: int, model: str) -> str: ...
21
+
22
+
23
+ def transport_is_mcp() -> bool:
24
+ return os.getenv("SKILLFORGE_TRANSPORT", "").strip().lower() == "mcp"
25
+
26
+
27
+ class AnthropicRouterLLM:
28
+ __slots__ = ("client", "backend_name")
29
+
30
+ def __init__(self, client: AsyncAnthropic) -> None:
31
+ self.client = client
32
+ self.backend_name = "anthropic"
33
+
34
+ async def complete(self, *, system: str, user: str, max_tokens: int, model: str) -> str:
35
+ resp = await self.client.messages.create(
36
+ model=model,
37
+ max_tokens=max_tokens,
38
+ system=system,
39
+ messages=[{"role": "user", "content": user}],
40
+ )
41
+ block = resp.content[0]
42
+ return block.text.strip() # type: ignore[attr-defined]
43
+
44
+
45
+ class OpenAIRouterLLM:
46
+ __slots__ = ("_client", "default_model", "backend_name")
47
+
48
+ def __init__(self, *, api_key: str, base_url: str, default_model: str) -> None:
49
+ from openai import AsyncOpenAI
50
+
51
+ self._client = AsyncOpenAI(api_key=api_key or "ollama", base_url=base_url.rstrip("/"))
52
+ self.default_model = default_model
53
+ self.backend_name = "openai_compatible"
54
+
55
+ async def complete(self, *, system: str, user: str, max_tokens: int, model: str) -> str:
56
+ m = model or self.default_model
57
+ resp = await self._client.chat.completions.create(
58
+ model=m,
59
+ max_tokens=max_tokens,
60
+ messages=[
61
+ {"role": "system", "content": system},
62
+ {"role": "user", "content": user},
63
+ ],
64
+ temperature=0,
65
+ )
66
+ choice = resp.choices[0]
67
+ return (choice.message.content or "").strip()
68
+
69
+
70
+ def resolve_openai_router_defaults() -> tuple[str, str, str]:
71
+ base = (
72
+ os.getenv("OPENAI_API_BASE", "").strip()
73
+ or os.getenv("SKILLFORGE_OPENAI_API_BASE", "").strip()
74
+ or "http://localhost:11434/v1"
75
+ )
76
+ api_key = (
77
+ os.getenv("OPENAI_API_KEY", "").strip()
78
+ or os.getenv("SKILLFORGE_OPENAI_API_KEY", "").strip()
79
+ )
80
+ model = (
81
+ os.getenv("SKILLFORGE_OPENAI_ROUTER_MODEL", "").strip()
82
+ or os.getenv("SKILLFORGE_CHAT_MODEL", "").strip()
83
+ or "llama3.2"
84
+ )
85
+ return base, api_key, model
@@ -0,0 +1,21 @@
1
+ """Normalize ``SKILLFORGE_ROUTER_MODE`` (default **host**, explicit ``auto`` for legacy routing)."""
2
+
3
+
4
+ def normalise_skillforge_router_mode(env_value: str) -> str:
5
+ """Return router mode token for ``app.main``
6
+
7
+ Expect ``env_value`` from ``os.getenv("SKILLFORGE_ROUTER_MODE", "host")``.
8
+
9
+ Returns
10
+ -------
11
+ ""
12
+ **auto** — use Haiku when ``ANTHROPIC_API_KEY`` is set, else embedding-first.
13
+ "host" | "embedding" | "full"
14
+ As selected.
15
+
16
+ **Unset** env → pass default ``"host"`` from ``getenv``. **Explicit auto:** ``auto`` or empty string.
17
+ """
18
+ s = (env_value or "").strip().lower()
19
+ if s in ("", "auto"):
20
+ return ""
21
+ return s
@@ -12,6 +12,11 @@ from app.route_quality import coerce_route_float
12
12
  _TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_\-./]{2,}", re.I)
13
13
 
14
14
 
15
+ def host_pick_max_candidates(*, top_k_cap: int) -> int:
16
+ """Caps host-mode numbered shortlist size (parity with ``run_route_turn``)."""
17
+ return max(3, min(top_k_cap, int(os.getenv("SKILLFORGE_HOST_PICK_MAX", "12"))))
18
+
19
+
15
20
  class _SkillCard(Protocol):
16
21
  title: str
17
22
  description: str
@@ -108,7 +113,8 @@ def host_pick_shortlist_lines(
108
113
  """Tight numbered list + structured rows for MCP host-pick phase (no in-process LLM)."""
109
114
  mc = max_candidates
110
115
  if mc is None:
111
- mc = max(3, int(os.getenv("SKILLFORGE_HOST_PICK_MAX", "12")))
116
+ top_k_cap = int(os.getenv("SKILLFORGE_TOP_K", "15"))
117
+ mc = host_pick_max_candidates(top_k_cap=top_k_cap)
112
118
  lc = line_chars if line_chars is not None else int(os.getenv("SKILLFORGE_HOST_PICK_LINE_CHARS", "120"))
113
119
  prompt_one = (prompt or "").strip().replace("\n", " ")
114
120
  if len(prompt_one) > 160:
@@ -0,0 +1,67 @@
1
+ """SKILL.md manifest checks (warnings + optional strict exclusion from catalog load)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Protocol, runtime_checkable
7
+
8
+
9
+ @runtime_checkable
10
+ class SkillLike(Protocol):
11
+ name: str
12
+ description: str
13
+ body: str
14
+ triggers: str
15
+ anti_triggers: str
16
+
17
+
18
+ _SKILL_DIR_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
19
+
20
+
21
+ def skill_md_has_yaml_frontmatter(path: Path) -> bool:
22
+ try:
23
+ head = path.read_text(encoding="utf-8")[:4096]
24
+ except OSError:
25
+ return False
26
+ return head.lstrip().startswith("---")
27
+
28
+
29
+ def validate_skill_manifest(skill: SkillLike, path: Path) -> tuple[list[str], list[str]]:
30
+ """Return (errors, warnings). Errors justify exclusion under strict manifest mode."""
31
+ errors: list[str] = []
32
+ warnings: list[str] = []
33
+ dir_name = path.parent.name
34
+ if skill.name != dir_name:
35
+ warnings.append(f"parsed name {skill.name!r} != dirname {dir_name!r} (unexpected)")
36
+
37
+ if not _SKILL_DIR_PATTERN.match(dir_name):
38
+ errors.append(f"skill directory {dir_name!r} should match {_SKILL_DIR_PATTERN.pattern}")
39
+
40
+ body = (skill.body or "").strip()
41
+ if len(body) < 1:
42
+ errors.append("empty SKILL.md body after frontmatter")
43
+
44
+ desc = (skill.description or "").strip()
45
+ if len(desc) < 40:
46
+ warnings.append(f"short description ({len(desc)} chars) — consider expanding frontmatter description")
47
+
48
+ if len(desc) > 2800:
49
+ warnings.append(f"very long description ({len(desc)} chars) — routing card may truncate awkwardly")
50
+
51
+ if not skill_md_has_yaml_frontmatter(path):
52
+ warnings.append("no YAML frontmatter block (---) — title/description inferred from filename/body")
53
+
54
+ trig = ((skill.triggers or "") + (skill.anti_triggers or "")).strip()
55
+ if len(trig) > 4000:
56
+ warnings.append(f"very long triggers/anti_triggers ({len(trig)} chars total)")
57
+
58
+ return errors, warnings
59
+
60
+
61
+ def skill_manifest_strict_exclusion() -> bool:
62
+ """When true, skip skills that fail manifest *errors* (see validate_skill_manifest)."""
63
+ import os
64
+
65
+ raw = os.getenv("SKILLFORGE_SKILL_MANIFEST_STRICT", "0").strip().lower()
66
+ return raw not in ("0", "false", "no", "")
67
+
@@ -0,0 +1,117 @@
1
+ """Scaffold user skills (`init`) and validate SKILL manifests (`lint`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from app.main import parse_skill_md
12
+ from app.skill_manifest import validate_skill_manifest
13
+
14
+ _SAFE_NAME = re.compile(r"^[a-z][a-z0-9_-]{1,62}$")
15
+
16
+
17
+ def skill_init(slug: str) -> Path:
18
+ if not _SAFE_NAME.match(slug):
19
+ raise ValueError(f"Skill folder name must match {_SAFE_NAME.pattern} — got {slug!r}")
20
+
21
+ skills_root = Path(os.getenv("SKILLFORGE_USER_SKILLS", str(Path.home() / ".skillforge" / "skills"))).expanduser().resolve()
22
+ dest = skills_root / slug
23
+ skill_md = dest / "SKILL.md"
24
+ if skill_md.exists():
25
+ raise ValueError(f"Already exists: {skill_md}")
26
+
27
+ dest.mkdir(parents=True, exist_ok=True)
28
+ body = (
29
+ "---\n"
30
+ f"name: {slug.replace('-', ' ').title()}\n"
31
+ "description: One-line pitch for routing (embedding + triggers card).\n"
32
+ "triggers: When does this skill apply?\n"
33
+ "anti_triggers: When should an agent skip this skill?\n"
34
+ "---\n\n"
35
+ f"# {slug}\n\n"
36
+ "## When to apply\n\n"
37
+ "- …\n\n"
38
+ "## Patterns\n\n"
39
+ "- …\n\n"
40
+ "## Verification\n\n"
41
+ "- …\n\n"
42
+ "## Outputs\n\n"
43
+ "- Expected shape of the assistant response\n"
44
+ )
45
+ skill_md.write_text(body, encoding="utf-8")
46
+ return skill_md
47
+
48
+
49
+ def skill_lint_roots(roots: list[Path]) -> int:
50
+ """Return count of ERROR-level manifest issues."""
51
+ errors = 0
52
+ for root in roots:
53
+ r = root.expanduser().resolve()
54
+ globs = [r] if r.is_file() and r.name == "SKILL.md" else sorted(r.glob("**/SKILL.md"))
55
+ if not globs:
56
+ print(f"No SKILL.md found under {r}")
57
+ errors += 1
58
+ continue
59
+ for md in globs:
60
+ parsed = parse_skill_md(md, "lint")
61
+ if not parsed:
62
+ print(f"ERROR {md}: unreadable parse")
63
+ errors += 1
64
+ continue
65
+ errs, warns = validate_skill_manifest(parsed, md)
66
+ for w in warns:
67
+ print(f"WARN {md}: {w}")
68
+ for e in errs:
69
+ print(f"ERROR {md}: {e}")
70
+ errors += 1
71
+ return errors
72
+
73
+
74
+ def main() -> None:
75
+ ap = argparse.ArgumentParser(description="Skillforge skill authoring helpers (scaffold + manifest lint).")
76
+ sub = ap.add_subparsers(dest="cmd", required=True)
77
+
78
+ p_init = sub.add_parser(
79
+ "init",
80
+ help="Create ~/.skillforge/skills/<slug>/SKILL.md (override SKILLFORGE_USER_SKILLS)",
81
+ )
82
+ p_init.add_argument("slug", help="Directory name under user skills folder (lowercase-kebab-case)")
83
+
84
+ p_lint = sub.add_parser(
85
+ "lint",
86
+ help="Validate SKILL.md files (see SKILLFORGE_SKILL_MANIFEST_STRICT for load-time exclusions)",
87
+ )
88
+ p_lint.add_argument(
89
+ "roots",
90
+ nargs="*",
91
+ default=[],
92
+ help="Directories or SKILL.md paths (default bundled + user trees from env)",
93
+ )
94
+
95
+ args = ap.parse_args()
96
+
97
+ try:
98
+ if args.cmd == "init":
99
+ p = skill_init(args.slug.strip().lower())
100
+ print(f"Wrote template: {p}")
101
+ print("(Reload MCP / hot reload to pick up the new catalog entry.)")
102
+ return
103
+ if args.cmd == "lint":
104
+ roots = [Path(p) for p in args.roots] if args.roots else []
105
+ if not roots:
106
+ bf = Path(os.getenv("SKILLFORGE_BUNDLED_SKILLS", "./skills")).expanduser().resolve()
107
+ usr = Path(os.getenv("SKILLFORGE_USER_SKILLS", str(Path.home() / ".skillforge" / "skills"))).expanduser()
108
+ roots = [bf, usr]
109
+ err_count = skill_lint_roots(roots)
110
+ raise SystemExit(1 if err_count else 0)
111
+ except ValueError as e:
112
+ print(f"{e}", file=sys.stderr)
113
+ raise SystemExit(1) from e
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,37 @@
1
+ """Short terminal+MCP cheatsheet emitted on demand (avoid loading the full README)."""
2
+
3
+
4
+ def main() -> None:
5
+ text = """# Skillforge — quick tips
6
+
7
+ ## After install (`skillforge install` or first `npx` run)
8
+
9
+ Your machine gets `~/.skillforge/` (venv, global DB, user skills).
10
+
11
+ Stable env (keys + tunables without shell paste): **`~/.skillforge/env`** — **`skillforge config path`**, **`skillforge config init`**, **`skillforge config validate`** (see README Configuration).
12
+
13
+ ## MCP (Cursor / Claude / …)
14
+
15
+ - Wire with `skillforge mcp config` (see README for JSON merge targets).
16
+ - Default routing is **host**: first `route_skills` returns a **shortlist**, second call sends `picked_names`.
17
+ - **`capabilities`** tool: one JSON bundle — schema version, tool list, router snapshot — good for session start.
18
+
19
+ ## Terminal (same engine as MCP)
20
+
21
+ - `skillforge route "your prompt"` — two-step **host** mode mirrors MCP (shortlist stdout, then re-run):
22
+ `skillforge route "…"` → `skillforge route "…" --picked-names=id1,id2`
23
+ - **`skillforge route -i`** (TTY) prompts for ranks/`1,3` or skill ids after the shortlist step.
24
+ Or set **`SKILLFORGE_ROUTE_INTERACTIVE=1`** (still requires a tty).
25
+ - **`skillforge route --json`** prints one JSON envelope (**`phase`**: `host_shortlist_prompt`, `host_shortlist_static`, `context`, `explain_only`) with `route_meta` for scripts.
26
+ - **`skillforge route --explain`** attaches routing diagnostics (**stderr** Markdown, unless `--json` embeds **`explain`**).
27
+ - **`skillforge route --explain-only`** — diagnostics only (**no finalize** / no session-heavy side effects comparable to MCP `explain_route` intent).
28
+ - **`skillforge tools …`** (**`skillforge tools -h`**): MCP-equivalent subcommands; **`--json`** emits full tool envelopes.
29
+ - **`skillforge agent`**: OpenAI-compatible chat calling the same MCP tool handlers (**`OPENAI_API_BASE`**, **`SKILLFORGE_AGENT_*`**). Run **`skillforge install`** after upgrades so **`openai`** is installed into **`~/.skillforge/venv`**.
30
+
31
+ Docs: **`package/README.md`**.
32
+ """
33
+ print(text, end="")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()