forgecommandline 0.8.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.
Files changed (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/sessions.py ADDED
@@ -0,0 +1,97 @@
1
+ """Saved conversations in ~/.forge/sessions/<id>.json."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import secrets
9
+ from dataclasses import dataclass, field
10
+
11
+ from .config import HOME_DIR
12
+
13
+ SESSION_DIR = HOME_DIR / "sessions"
14
+
15
+
16
+ def _now() -> str:
17
+ return dt.datetime.now().isoformat(timespec="seconds")
18
+
19
+
20
+ @dataclass
21
+ class Session:
22
+ id: str = field(default_factory=lambda: dt.datetime.now().strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(2))
23
+ title: str = ""
24
+ cwd: str = field(default_factory=lambda: os.getcwd())
25
+ created: str = field(default_factory=_now)
26
+ updated: str = field(default_factory=_now)
27
+ messages: list[dict] = field(default_factory=list) # everything after the system prompt
28
+ usage_in: int = 0
29
+ usage_out: int = 0
30
+
31
+ @property
32
+ def path(self) -> pathlib.Path:
33
+ return SESSION_DIR / f"{self.id}.json"
34
+
35
+ def save(self) -> None:
36
+ if not any(m.get("role") == "user" for m in self.messages):
37
+ return # don't litter the list with empty sessions
38
+ if not self.title:
39
+ first = next(m["content"] for m in self.messages if m.get("role") == "user")
40
+ self.title = " ".join(first.split())[:60]
41
+ self.updated = _now()
42
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
43
+ tmp = self.path.with_suffix(".tmp")
44
+ tmp.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=1), encoding="utf-8")
45
+ os.replace(tmp, self.path)
46
+
47
+ @classmethod
48
+ def load(cls, sid: str) -> "Session":
49
+ data = json.loads((SESSION_DIR / f"{sid}.json").read_text(encoding="utf-8"))
50
+ return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data})
51
+
52
+
53
+ def list_sessions() -> list[dict]:
54
+ """Newest first. Each item: id, title, cwd, updated, count."""
55
+ out = []
56
+ if not SESSION_DIR.is_dir():
57
+ return out
58
+ for f in SESSION_DIR.glob("*.json"):
59
+ try:
60
+ d = json.loads(f.read_text(encoding="utf-8"))
61
+ except (OSError, ValueError):
62
+ continue
63
+ out.append({"id": d.get("id", f.stem), "title": d.get("title", ""), "cwd": d.get("cwd", ""),
64
+ "updated": d.get("updated", ""),
65
+ "count": sum(1 for m in d.get("messages", []) if m.get("role") == "user")})
66
+ return sorted(out, key=lambda s: s["updated"], reverse=True)
67
+
68
+
69
+ def find(ref: str) -> str | None:
70
+ """Resolve '3' (row in /sessions), a full id, or an id prefix."""
71
+ items = list_sessions()
72
+ if ref.isdigit() and 1 <= int(ref) <= len(items):
73
+ return items[int(ref) - 1]["id"]
74
+ hits = [s["id"] for s in items if s["id"].startswith(ref)]
75
+ return hits[0] if len(hits) == 1 else None
76
+
77
+
78
+ def latest_for(cwd: str) -> str | None:
79
+ for s in list_sessions():
80
+ if os.path.normcase(s["cwd"]) == os.path.normcase(cwd):
81
+ return s["id"]
82
+ return None
83
+
84
+
85
+ def delete(sid: str) -> None:
86
+ (SESSION_DIR / f"{sid}.json").unlink(missing_ok=True)
87
+
88
+
89
+ def ago(stamp: str) -> str:
90
+ try:
91
+ secs = (dt.datetime.now() - dt.datetime.fromisoformat(stamp)).total_seconds()
92
+ except ValueError:
93
+ return stamp
94
+ for unit, n in (("d", 86400), ("h", 3600), ("m", 60)):
95
+ if secs >= n:
96
+ return f"{int(secs // n)}{unit} ago"
97
+ return "just now"
forgecli/skills.py ADDED
@@ -0,0 +1,446 @@
1
+ """Skills: reusable instructions stored as Markdown files.
2
+
3
+ A skill is either <dir>/<name>/SKILL.md or <dir>/<name>.md , with optional
4
+ front matter:
5
+
6
+ ---
7
+ name: review
8
+ description: Review uncommitted changes for bugs before committing
9
+ ---
10
+ Instructions for the model...
11
+
12
+ Searched (later wins on name clashes):
13
+ 0. claude every skill and plugin command Claude Code has installed on this machine
14
+ (~/.claude/skills, installed plugins, synced plugins) - read in place, never copied
15
+ 1. bundled <forge-cli>/skills/library - engineering skills that ship with Forge
16
+ (agent-skills and wondelai, MIT), so they work with no Claude Code and no sync
17
+ 2. built-in <forge-cli>/skills
18
+ 3. user ~/.forge/skills
19
+ 4. project <cwd>/.forge/skills
20
+
21
+ There can be a couple of hundred skills, so only Forge's own core ones are listed to the model;
22
+ the rest are found on demand with the find_skill tool (keeps every request small).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import datetime as dt
27
+ import json
28
+ import os
29
+ import pathlib
30
+ import sys
31
+ import re
32
+ import shutil
33
+ from dataclasses import dataclass
34
+
35
+ from .config import APP_DIR, HOME_DIR
36
+
37
+ NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,80}$")
38
+ CLAUDE_DIR = pathlib.Path(os.environ.get("CLAUDE_CONFIG_DIR") or pathlib.Path.home() / ".claude")
39
+ MAX_BODY = 12_000 # characters of a skill sent at once (fits small free-tier limits); rest via read_file
40
+
41
+ # Claude Code tool names that skills mention, and what to use in Forge instead.
42
+ TOOL_MAP = ("Tool names in this skill are Claude Code's. In Forge use: Bash/PowerShell → run_shell, "
43
+ "Read → read_file, Write → write_file, Edit/MultiEdit → edit_file, Grep → grep, "
44
+ "Glob → glob, WebFetch → web_fetch, WebSearch → web_search, TodoWrite/TaskCreate → todo, "
45
+ "AskUserQuestion → ask_user, Skill → use_skill (find_skill to look one up), "
46
+ "Agent/Task → delegate or run_parallel when enabled (otherwise do the steps yourself). "
47
+ "For MCP connectors, call mcp_search and then use only the exact loaded mcp__… tool schema. "
48
+ "For APIs, use http_request or import an OpenAPI spec; do not invent endpoints or parameters.")
49
+
50
+
51
+ @dataclass
52
+ class Skill:
53
+ name: str
54
+ description: str
55
+ path: pathlib.Path
56
+ source: str # claude | built-in | user | project
57
+ origin: str = "" # e.g. the Claude plugin it came from
58
+ needs: str = "" # connector the skill relies on that Forge doesn't have
59
+
60
+ def body(self) -> str:
61
+ text = _split(self.path.read_text(encoding="utf-8", errors="replace"))[1].strip()
62
+ base = str(self.path.parent)
63
+ root = str(_plugin_root(self.path) or self.path.parent)
64
+ for k, v in (("${CLAUDE_SKILL_DIR}", base), ("${CLAUDE_PLUGIN_ROOT}", root),
65
+ ("{baseDir}", base), ("$SKILL_DIR", base)):
66
+ text = text.replace(k, v)
67
+ if len(text) > MAX_BODY:
68
+ text = (text[:MAX_BODY] + f"\n\n… [skill continues: read_file {self.path} "
69
+ f"with offset to read the rest]")
70
+ return text
71
+
72
+ def full(self) -> str:
73
+ """What use_skill returns: where the skill lives, how to map tools, then its text."""
74
+ head = [f"Skill '{self.name}'" + (f" (from Claude Code: {self.origin})" if self.origin else ""),
75
+ f"Its folder: {self.path.parent} — files it mentions (references/, scripts/, "
76
+ f"templates) are there; read them with read_file, run scripts with run_shell."]
77
+ if self.source in ("claude", "bundled"):
78
+ head.append(TOOL_MAP)
79
+ if self.needs:
80
+ head.append(f"Note: this skill comes from the {self.needs} plugin, which may rely on online "
81
+ "service connectors. Search configured MCP servers with mcp_search; if no matching "
82
+ "tool is available, do the parts that work and report what needs that service.")
83
+ return "\n".join(head) + "\n\n" + self.body()
84
+
85
+
86
+ def _split(text: str) -> tuple[dict, str]:
87
+ """Front matter → dict. Handles one-line values and YAML block values (> or |)."""
88
+ meta: dict[str, str] = {}
89
+ if text.startswith(""):
90
+ text = text[1:]
91
+ if not text.startswith("---"):
92
+ return meta, text
93
+ end = text.find("\n---", 3)
94
+ if end == -1:
95
+ return meta, text
96
+ key = None
97
+ for line in text[3:end].splitlines():
98
+ if key and (line.startswith((" ", "\t")) or not line.strip()):
99
+ meta[key] = (meta[key] + " " + line.strip()).strip()
100
+ continue
101
+ key = None
102
+ if ":" in line and not line.startswith((" ", "\t", "-")):
103
+ k, v = line.split(":", 1)
104
+ k, v = k.strip().lower(), v.strip()
105
+ if v in (">", "|", ">-", "|-", ""):
106
+ meta[k], key = "", k
107
+ else:
108
+ meta[k] = v.strip('"').strip("'")
109
+ return meta, text[end + 4:]
110
+
111
+
112
+ def _plugin_root(path: pathlib.Path) -> pathlib.Path | None:
113
+ for parent in path.parents:
114
+ if (parent / ".claude-plugin").is_dir():
115
+ return parent
116
+ if parent in (CLAUDE_DIR, LIBRARY):
117
+ break
118
+ return None
119
+
120
+
121
+ # Forge's own copy of Claude Code's skills, so they keep working without Claude Code.
122
+ LIBRARY = HOME_DIR / "library"
123
+ MANIFEST = "manifest.json"
124
+ _SKIP = shutil.ignore_patterns(".git", "node_modules", "__pycache__", ".venv", "venv", "*.pyc")
125
+
126
+
127
+ def synced_info() -> dict:
128
+ try:
129
+ return json.loads((LIBRARY / MANIFEST).read_text(encoding="utf-8"))
130
+ except (OSError, ValueError):
131
+ return {}
132
+
133
+
134
+ def sync() -> dict:
135
+ """Copy every skill Claude Code has (with its scripts and reference files) into ~/.forge/library.
136
+ Built in a temp folder and swapped in, so a failed copy never leaves a half library."""
137
+ sources = _claude_sources()
138
+ if not sources:
139
+ raise RuntimeError(f"no Claude Code skills found in {CLAUDE_DIR}")
140
+ tmp = LIBRARY.with_name("library.new")
141
+ shutil.rmtree(tmp, ignore_errors=True)
142
+ tmp.mkdir(parents=True)
143
+ plugins: dict[str, pathlib.Path] = {}
144
+ for d, plugin in sources:
145
+ if plugin:
146
+ plugins.setdefault(plugin, _plugin_root(d) or d.parent)
147
+ if (CLAUDE_DIR / "skills").is_dir():
148
+ shutil.copytree(CLAUDE_DIR / "skills", tmp / "skills", ignore=_SKIP)
149
+ for name, root in plugins.items():
150
+ shutil.copytree(root, tmp / "plugins" / name, ignore=_SKIP, dirs_exist_ok=True)
151
+ info = {"synced": dt.datetime.now().isoformat(timespec="seconds"), "from": str(CLAUDE_DIR),
152
+ "plugins": sorted(plugins)}
153
+ (tmp / MANIFEST).write_text(json.dumps(info, indent=1), encoding="utf-8")
154
+ old = LIBRARY.with_name("library.old")
155
+ shutil.rmtree(old, ignore_errors=True)
156
+ if LIBRARY.exists():
157
+ LIBRARY.rename(old)
158
+ tmp.rename(LIBRARY)
159
+ shutil.rmtree(old, ignore_errors=True)
160
+ return info
161
+
162
+
163
+ def _library_sources() -> list[tuple[pathlib.Path, str]]:
164
+ """Same shape as _claude_sources(), but from Forge's own copy."""
165
+ out: list[tuple[pathlib.Path, str]] = []
166
+ skills = LIBRARY / "skills"
167
+ if skills.is_dir():
168
+ out.append((skills, ""))
169
+ if (skills / "synced").is_dir():
170
+ out += [(d, "") for d in (skills / "synced").iterdir() if d.is_dir()]
171
+ plugins = LIBRARY / "plugins"
172
+ if plugins.is_dir():
173
+ for p in sorted(plugins.iterdir()):
174
+ for sub in ("skills", "commands"):
175
+ if (p / sub).is_dir():
176
+ out.append((p / sub, p.name))
177
+ return out
178
+
179
+
180
+ def _sources() -> list[tuple[pathlib.Path, str]]:
181
+ """Forge's copy when there is one (made automatically the first time Claude Code's skills are seen)."""
182
+ if not (LIBRARY / MANIFEST).is_file() and CLAUDE_DIR.is_dir():
183
+ try:
184
+ sync()
185
+ except Exception: # can't copy? read them in place this time
186
+ return _claude_sources()
187
+ return _library_sources() if (LIBRARY / MANIFEST).is_file() else _claude_sources()
188
+
189
+
190
+ def _needs(root: pathlib.Path | None, plugin: str) -> str:
191
+ if root and ((root / ".mcp.json").is_file() or (root / "mcp.json").is_file()):
192
+ return plugin or root.name.split("~")[0]
193
+ return ""
194
+
195
+
196
+ def _claude_sources() -> list[tuple[pathlib.Path, str]]:
197
+ """(folder holding skills or commands, plugin name or '') for everything Claude Code has installed."""
198
+ out: list[tuple[pathlib.Path, str]] = []
199
+ if not CLAUDE_DIR.is_dir():
200
+ return out
201
+ skills = CLAUDE_DIR / "skills"
202
+ if skills.is_dir():
203
+ out.append((skills, ""))
204
+ synced = skills / "synced"
205
+ if synced.is_dir():
206
+ out += [(d, "") for d in synced.iterdir() if d.is_dir()]
207
+ plugins: list[pathlib.Path] = []
208
+ try:
209
+ idx = json.loads((CLAUDE_DIR / "plugins" / "installed_plugins.json").read_text(encoding="utf-8"))
210
+ for entries in (idx.get("plugins") or {}).values():
211
+ for e in entries if isinstance(entries, list) else [entries]:
212
+ p = pathlib.Path(e.get("installPath", ""))
213
+ if p.is_dir():
214
+ plugins.append(p)
215
+ except (OSError, ValueError, AttributeError):
216
+ pass
217
+ synced = CLAUDE_DIR / "plugins" / "synced"
218
+ if synced.is_dir():
219
+ for bucket in synced.iterdir():
220
+ if bucket.is_dir() and not bucket.name.startswith("."):
221
+ plugins += [p for p in bucket.iterdir() if p.is_dir() and not p.name.startswith(".")]
222
+ for p in plugins:
223
+ name = p.name if not re.match(r"^\d+\.\d+", p.name) else p.parent.name
224
+ try:
225
+ name = json.loads((p / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))["name"]
226
+ except (OSError, ValueError, KeyError, TypeError):
227
+ pass
228
+ name = str(name).split("~")[0]
229
+ for sub in ("skills", "commands"):
230
+ if (p / sub).is_dir():
231
+ out.append((p / sub, name))
232
+ return out
233
+
234
+
235
+ def _entries(d: pathlib.Path):
236
+ """Skill files directly in d: <name>/SKILL.md or <name>.md."""
237
+ try:
238
+ items = sorted(d.iterdir())
239
+ except OSError:
240
+ return
241
+ for entry in items:
242
+ if entry.is_dir() and (entry / "SKILL.md").is_file():
243
+ yield entry / "SKILL.md", entry.name
244
+ elif entry.is_file() and entry.suffix.lower() == ".md" and entry.stem.upper() != "README":
245
+ yield entry, entry.stem
246
+
247
+
248
+ def _load(path: pathlib.Path, default: str) -> tuple[str, str] | None:
249
+ try:
250
+ meta, body = _split(path.read_text(encoding="utf-8", errors="replace"))
251
+ except OSError:
252
+ return None
253
+ name = (meta.get("name") or default).strip().lower().replace(" ", "-")
254
+ if not NAME_RE.match(name):
255
+ return None
256
+ desc = meta.get("description") or next(
257
+ (ln.strip("# ").strip() for ln in body.splitlines() if ln.strip()), "")
258
+ return name, " ".join(desc.split())
259
+
260
+
261
+ def discover() -> dict[str, Skill]:
262
+ found: dict[str, Skill] = {}
263
+ # Claude Code's skills first, so Forge's own (and yours) win on a name clash.
264
+ for d, plugin in _sources():
265
+ root = _plugin_root(d)
266
+ needs = _needs(root, plugin)
267
+ for path, default in _entries(d):
268
+ got = _load(path, default)
269
+ if not got:
270
+ continue
271
+ name, desc = got
272
+ sk = Skill(name, desc, path, "claude", plugin or "skills", needs)
273
+ if plugin:
274
+ found[f"{plugin}:{name}"] = Skill(f"{plugin}:{name}", desc, path, "claude", plugin, needs)
275
+ # The short name goes to the first skill that has it, preferring ones that need no connector.
276
+ if name not in found or (found[name].needs and not needs):
277
+ found[name] = sk
278
+ for source, d in skill_dirs():
279
+ if not d.is_dir():
280
+ continue
281
+ for path, default in _entries(d):
282
+ got = _load(path, default)
283
+ if got:
284
+ found[got[0]] = Skill(got[0], got[1], path, source)
285
+ return found
286
+
287
+
288
+ def skill_dirs() -> list[tuple[str, pathlib.Path]]:
289
+ installed = pathlib.Path(sys.prefix) / "skills"
290
+ builtin = installed if installed.is_dir() else APP_DIR / "skills"
291
+ return [("bundled", builtin / "library"),
292
+ ("built-in", builtin),
293
+ ("user", HOME_DIR / "skills"),
294
+ ("project", pathlib.Path.cwd() / ".forge" / "skills")]
295
+
296
+
297
+ def listed(skills: dict[str, Skill]) -> list[Skill]:
298
+ """Skills named in the system prompt: Forge's own plus yours. The rest are found with find_skill."""
299
+ return [s for s in skills.values() if s.source not in ("claude", "bundled")]
300
+
301
+
302
+ def find(skills: dict[str, Skill], query: str, limit: int = 8) -> list[Skill]:
303
+ words = [w for w in re.findall(r"[a-z0-9]+", query.lower()) if len(w) > 1]
304
+ if not words:
305
+ return []
306
+ seen_paths, scored = set(), []
307
+ for s in skills.values():
308
+ if s.path in seen_paths:
309
+ continue
310
+ name, desc = s.name.lower(), s.description.lower()
311
+ score = sum(3 * (w in name) + desc.count(w) for w in words)
312
+ if score:
313
+ scored.append((score - (2 if s.needs else 0), s))
314
+ scored.sort(key=lambda x: -x[0])
315
+ out = []
316
+ for _, s in scored:
317
+ if s.path not in seen_paths:
318
+ seen_paths.add(s.path)
319
+ out.append(s)
320
+ if len(out) >= limit:
321
+ break
322
+ return out
323
+
324
+
325
+ # --- automatic skill detection --------------------------------------------------------
326
+ # A local BM25 index over skill names + descriptions: picks the skill a request needs in about a
327
+ # millisecond, with no model call, so the right instructions ride along with the very first request.
328
+
329
+ _STOP = set("""a an the and or but if then so to of in on at by for with from into onto about as is are was
330
+ were be been being it its this that these those i me my we our you your he she they them their what which
331
+ who whom how why when where can could should would will shall may might must do does did done make made
332
+ get got have has had please just also some any all more most very really want need like use using used
333
+ help me let lets let's one two new now up out over not no yes thing things stuff something way forge forgecli""".split())
334
+
335
+
336
+ def _stem(w: str) -> str:
337
+ for suf in ("ing", "ies", "es", "ed", "s"):
338
+ if len(w) > len(suf) + 2 and w.endswith(suf):
339
+ return w[: -len(suf)] + ("y" if suf == "ies" else "")
340
+ return w
341
+
342
+
343
+ def _terms(text: str) -> list[str]:
344
+ return [_stem(w) for w in re.findall(r"[a-z0-9]+", text.lower()) if len(w) > 1 and w not in _STOP]
345
+
346
+
347
+ class Matcher:
348
+ """Built once per skill set. match(text) → (skill, confidence) or (None, 0)."""
349
+ K1, B = 1.2, 0.6
350
+ MIN_SCORE = 6.0 # below this nothing is confidently relevant
351
+ MIN_LEAD = 1.25 # the winner must beat the runner-up by this factor
352
+
353
+ def __init__(self, skills: dict[str, "Skill"]):
354
+ import math
355
+ uniq, seen = [], set()
356
+ for s in skills.values():
357
+ if s.path not in seen:
358
+ seen.add(s.path)
359
+ uniq.append(s)
360
+ self.docs = []
361
+ df: dict[str, int] = {}
362
+ for s in uniq:
363
+ name_terms = _terms(s.name.replace("-", " ").replace(":", " "))
364
+ terms = name_terms * 3 + _terms(s.description) # the name counts triple
365
+ tf: dict[str, int] = {}
366
+ for t in terms:
367
+ tf[t] = tf.get(t, 0) + 1
368
+ for t in tf:
369
+ df[t] = df.get(t, 0) + 1
370
+ self.docs.append((s, tf, len(terms)))
371
+ n = max(1, len(self.docs))
372
+ self.avg = sum(d[2] for d in self.docs) / n if self.docs else 1
373
+ self.idf = {t: math.log(1 + (n - c + 0.5) / (c + 0.5)) for t, c in df.items()}
374
+ # IDF (and so scores) shrink with fewer skills; scale the bar to match (tuned on ~200 skills)
375
+ self.min_score = self.MIN_SCORE * min(1.0, math.log(1 + n) / math.log(201))
376
+
377
+ def scores(self, text: str) -> list[tuple[float, "Skill"]]:
378
+ q = set(_terms(text))
379
+ out = []
380
+ for s, tf, length in self.docs:
381
+ sc = 0.0
382
+ for t in q:
383
+ f = tf.get(t)
384
+ if f:
385
+ sc += self.idf[t] * f * (self.K1 + 1) / (f + self.K1 * (1 - self.B + self.B * length / self.avg))
386
+ if sc:
387
+ if s.needs: # needs a connector Forge doesn't have
388
+ sc *= 0.6
389
+ if s.source != "claude":
390
+ sc *= 1.15 # Forge's own skills fit Forge's tools best
391
+ out.append((sc, s))
392
+ out.sort(key=lambda x: -x[0])
393
+ return out
394
+
395
+ def match(self, text: str) -> tuple["Skill | None", float]:
396
+ if len(text) < 12 or text.lstrip().startswith("/"):
397
+ return None, 0.0
398
+ ranked = self.scores(text)
399
+ if not ranked:
400
+ return None, 0.0
401
+ best, s = ranked[0]
402
+ second = ranked[1][0] if len(ranked) > 1 else 0.0
403
+ if best >= self.min_score and best >= second * self.MIN_LEAD:
404
+ return s, best
405
+ return None, best
406
+
407
+
408
+ def create(name: str, project: bool = False) -> pathlib.Path:
409
+ name = name.strip().lower()
410
+ if not re.match(r"^[a-z0-9][a-z0-9_-]{0,40}$", name):
411
+ raise ValueError("use lowercase letters, digits, - or _ (max 41 chars)")
412
+ base = (pathlib.Path.cwd() / ".forge" / "skills") if project else (HOME_DIR / "skills")
413
+ path = base / name / "SKILL.md"
414
+ if path.exists():
415
+ raise ValueError(f"{path} already exists")
416
+ path.parent.mkdir(parents=True, exist_ok=True)
417
+ path.write_text(f"""---
418
+ name: {name}
419
+ description: One line saying what this skill does and when to use it
420
+ ---
421
+ # {name}
422
+
423
+ Write the instructions the agent should follow when this skill is used.
424
+ For example: which files to look at, which commands to run, what the
425
+ result should look like, and what to avoid.
426
+ """, encoding="utf-8")
427
+ return path
428
+
429
+
430
+ def use_skill_schema(skills: dict[str, Skill]) -> dict:
431
+ return {"type": "function", "function": {
432
+ "name": "use_skill",
433
+ "description": "Load the full instructions of a skill (by exact name, from the list or from "
434
+ "find_skill). Call it before starting a task that matches, then follow them.",
435
+ "parameters": {"type": "object", "properties": {
436
+ "name": {"type": "string"}}, "required": ["name"]}}}
437
+
438
+
439
+ FIND_SCHEMA = {"type": "function", "function": {
440
+ "name": "find_skill",
441
+ "description": "Search the library of expert skills (engineering, web development, design, testing, "
442
+ "debugging, security, data, documents like pdf/docx/xlsx/pptx, research, marketing, "
443
+ "sales, video, audio…) by keywords. Use it at the start of any non-trivial task.",
444
+ "parameters": {"type": "object", "properties": {
445
+ "query": {"type": "string", "description": "a few keywords, e.g. 'react frontend ui'"}},
446
+ "required": ["query"]}}}