@dforge-core/dforge-mcp 0.1.13 → 0.2.1
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.
- package/CHANGELOG.md +231 -0
- package/README.md +106 -72
- package/dist/server.js +2322 -393
- package/docs/creating-modules.md +21 -10
- package/package.json +7 -4
- package/resources/docs/dsl-reference.md +27 -6
- package/resources/schemas/domains.schema.json +78 -0
- package/resources/schemas/entity.schema.json +18 -0
- package/resources/schemas/triggers.schema.json +5 -5
- package/scripts/install-skills.mjs +165 -0
- package/skills/dforge-mcp-author/SKILL.md +147 -471
- package/skills/dforge-mcp-author/references/action-dsl.md +36 -1
- package/skills/dforge-module-build/SKILL.md +309 -0
- package/skills/dforge-module-design/SKILL.md +125 -0
- package/skills/dforge-module-ship/SKILL.md +163 -0
package/docs/creating-modules.md
CHANGED
|
@@ -42,20 +42,28 @@ Sidebar also gives you right-click commands: **Pack Module**, **Install Module t
|
|
|
42
42
|
|
|
43
43
|
## Path 3 — AI co-pilot, wizard-driven
|
|
44
44
|
|
|
45
|
-
Claude Code (or Cursor / Zed) with `dforge-mcp` connected. You describe what you want; the AI walks you through
|
|
45
|
+
Claude Code (or Cursor / Zed) with `dforge-mcp` connected. You describe what you want; the AI walks you through seven phases (0-6) via MCP tool calls, pausing at each gate for your approval. Guidance ships as four skills — a router plus one per lifecycle stage — so only the active stage is loaded.
|
|
46
46
|
|
|
47
47
|
Setup once:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
50
|
claude mcp add dforge --scope user -- npx -y @dforge-core/dforge-mcp
|
|
51
|
-
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
mkdir -p ~/.claude/skills/dforge-mcp-author
|
|
55
|
-
curl -fsSL "https://cdn.jsdelivr.net/npm/@dforge-core/dforge-mcp@${VERSION}/skills/dforge-mcp-author/SKILL.md" \
|
|
56
|
-
-o ~/.claude/skills/dforge-mcp-author/SKILL.md
|
|
51
|
+
|
|
52
|
+
# Install the four authoring skills into ~/.claude/skills/
|
|
53
|
+
npx -y -p @dforge-core/dforge-mcp dforge-install-skills
|
|
57
54
|
```
|
|
58
55
|
|
|
56
|
+
That works as-is on Windows (cmd.exe / PowerShell), macOS and Linux — it's a
|
|
57
|
+
Node script, not bash. From a clone, `npm run install-skills` installs the local
|
|
58
|
+
copy instead. See the README's "Windows notes" for the WSL caveat.
|
|
59
|
+
|
|
60
|
+
| Skill | Stage |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `dforge-mcp-author` | Router — finds the current phase and hands off |
|
|
63
|
+
| `dforge-module-design` | Phase 0 — identity, requirements, design, validation |
|
|
64
|
+
| `dforge-module-build` | Phases 1-5 — entities, behavior, views, security |
|
|
65
|
+
| `dforge-module-ship` | Phase 6 — validate, pack, install |
|
|
66
|
+
|
|
59
67
|
Restart Claude Code, approve the MCP server on first prompt, then in a new conversation:
|
|
60
68
|
|
|
61
69
|
```
|
|
@@ -66,7 +74,7 @@ The wizard runs:
|
|
|
66
74
|
|
|
67
75
|
### Phase 0 — Identity, requirements, design, validation (required)
|
|
68
76
|
|
|
69
|
-
A documented chain the `dforge-
|
|
77
|
+
A documented chain the `dforge-module-design` skill enforces — the AI authors each artifact directly (drafts it, you approve, your client writes the file):
|
|
70
78
|
|
|
71
79
|
- **0a Identity** — write `CLAUDE.md` (identity + MCP-first rules + a live status tracker).
|
|
72
80
|
- **0b Requirements** — intake questions (purpose / users / dependencies / language scope), then write `docs/REQUIREMENTS.md`. **You review it before moving on.**
|
|
@@ -148,6 +156,9 @@ VS Code with the [dForge extension](https://github.com/dforge-core/dforge-editor
|
|
|
148
156
|
|
|
149
157
|
## Going further
|
|
150
158
|
|
|
151
|
-
- [
|
|
152
|
-
- [
|
|
159
|
+
- [dforge-mcp-author](../skills/dforge-mcp-author/SKILL.md) — the router: session start, tool inventory, hard rules, tool-failure protocol, resource index
|
|
160
|
+
- [dforge-module-design](../skills/dforge-module-design/SKILL.md) — Phase 0, including the intake guardrails and gap scans
|
|
161
|
+
- [dforge-module-build](../skills/dforge-module-build/SKILL.md) — Phases 1-5, the loading-policy table, core-rules cheat sheet, and the deterministic backtrack protocol
|
|
162
|
+
- [dforge-module-ship](../skills/dforge-module-ship/SKILL.md) — Phase 6 and the install-fix loop
|
|
163
|
+
- [README.md](../README.md) — full tool reference (34 tools) + maintainer docs
|
|
153
164
|
- [iash44/dForge-core](https://github.com/iash44/dForge-core) — the platform itself: source of truth for the schemas + DSL conventions
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/dforge-mcp",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "MCP server for dForge module authoring. Exposes scaffold/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/iash44/dForge-core",
|
|
7
7
|
"repository": {
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
"url": "git+https://github.com/dforge-core/dforge-mcp.git"
|
|
10
10
|
},
|
|
11
11
|
"bin": {
|
|
12
|
-
"dforge-mcp": "dist/server.js"
|
|
12
|
+
"dforge-mcp": "dist/server.js",
|
|
13
|
+
"dforge-install-skills": "scripts/install-skills.mjs"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"dist/",
|
|
16
17
|
"docs/",
|
|
17
18
|
"resources/",
|
|
18
19
|
"skills/",
|
|
20
|
+
"scripts/install-skills.mjs",
|
|
19
21
|
"README.md",
|
|
20
22
|
"CHANGELOG.md"
|
|
21
23
|
],
|
|
@@ -28,7 +30,8 @@
|
|
|
28
30
|
"sync-schemas": "node scripts/vendor-schemas.cjs",
|
|
29
31
|
"prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
|
|
30
32
|
"typecheck": "tsc --noEmit",
|
|
31
|
-
"test": "vitest run"
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"install-skills": "node scripts/install-skills.mjs"
|
|
32
35
|
},
|
|
33
36
|
"dependencies": {
|
|
34
37
|
"@dforge-core/dforge-cli": "^0.2.7",
|
|
@@ -90,7 +90,8 @@ execute:
|
|
|
90
90
|
### Current record (single / each mode)
|
|
91
91
|
|
|
92
92
|
- `[fieldName]` — read or write a field on the current record
|
|
93
|
-
- `[refField].[targetField]` — navigate via a Reference (FK) column to a field on the linked entity
|
|
93
|
+
- `[refField].[targetField]` — navigate via a Reference (FK) column to a field on the linked entity. `[refField].targetField` (bare target) is accepted as an equivalent spelling. **Single-hop only** — `[a].[b].[c]` is a compile error; use `getRecord()` / `select()` to reach past one reference
|
|
94
|
+
- `old[fieldName]` — the field's value *before* the change, in an action invoked by an event trigger. Read-only; reads `null` on `insert` events and outside triggers
|
|
94
95
|
|
|
95
96
|
```dsl
|
|
96
97
|
# Read
|
|
@@ -151,6 +152,12 @@ if (open.length == 0) { exit('Nothing open', 'info') }
|
|
|
151
152
|
info('Open invoices: ' + open.length + ', total $' + open[0].total)
|
|
152
153
|
```
|
|
153
154
|
|
|
155
|
+
> **Pooled connection.** The connection is handed to another request once your
|
|
156
|
+
> statement finishes — so don't leave state behind on it: `SET LOCAL` not `SET`,
|
|
157
|
+
> `pg_advisory_xact_lock()` not `pg_advisory_lock()`, `ON COMMIT DROP` on temp
|
|
158
|
+
> tables. Don't assume the whole action is one transaction either — that depends
|
|
159
|
+
> on the execution path. Applies to `query()` only.
|
|
160
|
+
|
|
154
161
|
#### `insert(entityCd, fields) → record`
|
|
155
162
|
Insert a row. Returns the inserted record as a dictionary (PKs populated, defaults applied, audit columns set).
|
|
156
163
|
|
|
@@ -312,7 +319,10 @@ sendEmail([customer_id].[email], 'order_confirmation', {
|
|
|
312
319
|
```
|
|
313
320
|
|
|
314
321
|
#### `error(message)`
|
|
315
|
-
**Throw** — aborts execution
|
|
322
|
+
**Throw** — aborts execution immediately. Use for validation failures. Whether
|
|
323
|
+
prior `insert()` / writes in this `execute:` block are rolled back **depends on the
|
|
324
|
+
execution path** and is not something to rely on — validate before writing
|
|
325
|
+
anything (see the anti-pattern near the end of this document).
|
|
316
326
|
|
|
317
327
|
```dsl
|
|
318
328
|
if ([rating] == null || [rating] < 1 || [rating] > 5) {
|
|
@@ -766,17 +776,28 @@ execute:
|
|
|
766
776
|
[last_touched] = now()
|
|
767
777
|
```
|
|
768
778
|
|
|
769
|
-
### ❌ Assuming `error()`
|
|
779
|
+
### ❌ Assuming you know what `error()` does to work done above
|
|
770
780
|
|
|
771
|
-
`error()`
|
|
781
|
+
`error()` is a hard abort — never use it to "log and continue". What happens to
|
|
782
|
+
writes made *before* it is **not something you can rely on**: it depends on the
|
|
783
|
+
execution path. The same script keeps the row on one path and discards it on
|
|
784
|
+
another, and the difference isn't visible in the action.
|
|
772
785
|
|
|
773
786
|
```dsl
|
|
774
|
-
# WRONG —
|
|
787
|
+
# WRONG — is this insert kept or discarded? Depends on the path.
|
|
775
788
|
execute:
|
|
776
|
-
insert('audit_log', { event: 'attempted_close', record_id: [id] }) #
|
|
789
|
+
insert('audit_log', { event: 'attempted_close', record_id: [id] }) # unreliable!
|
|
777
790
|
if ([balance_due] > 0) { error('Cannot close — balance owing') }
|
|
778
791
|
```
|
|
779
792
|
|
|
793
|
+
Whether an `error()` unwinds earlier writes turns on whether that path opened a
|
|
794
|
+
transaction — for a synchronous action, on whether the entity has `auditHistory`
|
|
795
|
+
**and** whether the mode is `single` (in `batch` mode nothing covers the script);
|
|
796
|
+
a background action has none at all and keeps everything written before the
|
|
797
|
+
failure. This is a platform defect rather than a contract — actions are intended
|
|
798
|
+
to be atomic — so don't design around it either way. Write the script so the
|
|
799
|
+
question never arises: validate first.
|
|
800
|
+
|
|
780
801
|
```dsl
|
|
781
802
|
# RIGHT — validate first, then perform side-effects
|
|
782
803
|
execute:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://dforge.dev/schemas/domains.schema.json",
|
|
4
|
+
"title": "dForge Column Domains",
|
|
5
|
+
"description": "domains.json — reusable, named semantic types. A domain bundles a base datatype, a control, sizing and a shared option list under one code; columns reference it via \"domain\": \"module_cd.domain_cd\" instead of restating the definition. Structural fields are materialized onto each column at install; options stay on the domain and resolve at metadata-load time, so a shared list is authored and translated exactly once.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": {
|
|
8
|
+
"$ref": "#/$defs/domain"
|
|
9
|
+
},
|
|
10
|
+
"propertyNames": {
|
|
11
|
+
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
|
12
|
+
"description": "Domain code. Must not contain '.', which is reserved for the qualified 'module_cd.domain_cd' form."
|
|
13
|
+
},
|
|
14
|
+
"$defs": {
|
|
15
|
+
"domain": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["baseDatatypeCd"],
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"description": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Human-readable label. Localizable via translations/<locale>.json under domains.<domain_cd>.label."
|
|
23
|
+
},
|
|
24
|
+
"baseDatatypeCd": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Base datatype the domain stands for (e.g. string, number, date, datetime, boolean). Required — it is what consuming columns' DDL is generated from."
|
|
27
|
+
},
|
|
28
|
+
"dbDatatype": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Physical Postgres type for consuming columns (e.g. varchar, int4, numeric). Omit to let it be inferred from fieldTypeCd."
|
|
31
|
+
},
|
|
32
|
+
"fieldTypeCd": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Control used to render/edit consuming columns (e.g. dropdown, radio, flags, text, number)."
|
|
35
|
+
},
|
|
36
|
+
"maxLen": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"minimum": 1,
|
|
39
|
+
"description": "Max length for string-backed domains."
|
|
40
|
+
},
|
|
41
|
+
"precision": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"description": "Decimal precision for numeric domains."
|
|
45
|
+
},
|
|
46
|
+
"params": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"description": "Field-type config shared by every consuming column — most importantly the 'options' list for dropdown/radio/flags. Never copied onto columns: it resolves from the domain at load time, which is what lets its labels be translated once for all consumers.",
|
|
49
|
+
"properties": {
|
|
50
|
+
"options": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"description": "Option list. Each entry is either a bare string (value doubles as label) or an object. Per-locale label/icon/color overrides live in translations/<locale>.json under domains.<domain_cd>.options.<value>.",
|
|
53
|
+
"items": {
|
|
54
|
+
"oneOf": [
|
|
55
|
+
{ "type": "string" },
|
|
56
|
+
{
|
|
57
|
+
"type": "object",
|
|
58
|
+
"required": ["value"],
|
|
59
|
+
"additionalProperties": false,
|
|
60
|
+
"properties": {
|
|
61
|
+
"value": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "The stored code. Never translatable — translations match on it."
|
|
64
|
+
},
|
|
65
|
+
"label": { "type": "string" },
|
|
66
|
+
"icon": { "type": "string" },
|
|
67
|
+
"color": { "type": "string" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -209,11 +209,29 @@
|
|
|
209
209
|
"type": "boolean",
|
|
210
210
|
"description": "On a self-referencing reference column (R column whose link.entity is the owning entity), set true to ALLOW parent/child cycles. When absent the self-reference is treated as an acyclic hierarchy and DB-level guards reject loops. Ignored on non-self-referencing columns."
|
|
211
211
|
},
|
|
212
|
+
"domain": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": "Reference to a reusable column domain, qualified as 'module_cd.domain_cd' (unqualified resolves within this module, then tenant-created domains). The domain supplies dbDatatype, baseDatatypeCd, fieldTypeCd, maxLen, precision and params; declaring any of those alongside 'domain' is rejected at install. The column keeps its own description, flags, orderNum, isNullable and isPk."
|
|
215
|
+
},
|
|
212
216
|
"refFilter": {
|
|
213
217
|
"description": "Filter criteria for reference lookups. Condition values support @[field] placeholders resolved against the current record at lookup time (e.g. {\"c\":\"channel\",\"o\":\"eq\",\"v\":\"@[channel]\"} narrows options to rows matching the record's own channel); a condition whose placeholder is unset is dropped rather than matching nothing."
|
|
214
218
|
}
|
|
215
219
|
},
|
|
216
220
|
"oneOf": [
|
|
221
|
+
{
|
|
222
|
+
"title": "Domain-backed column",
|
|
223
|
+
"description": "Physical column whose datatype, control, sizing and options all come from a reusable column domain. Requires 'domain' and must NOT restate what the domain owns.",
|
|
224
|
+
"required": ["domain"],
|
|
225
|
+
"properties": {
|
|
226
|
+
"link": false,
|
|
227
|
+
"dbDatatype": false,
|
|
228
|
+
"baseDatatypeCd": false,
|
|
229
|
+
"fieldTypeCd": false,
|
|
230
|
+
"maxLen": false,
|
|
231
|
+
"precision": false,
|
|
232
|
+
"params": false
|
|
233
|
+
}
|
|
234
|
+
},
|
|
217
235
|
{
|
|
218
236
|
"title": "Data column",
|
|
219
237
|
"description": "Physical DB column (columnType 'D' or unspecified). Requires 'dbDatatype'. Must NOT have 'link'.",
|
|
@@ -37,20 +37,20 @@
|
|
|
37
37
|
},
|
|
38
38
|
"condition": {
|
|
39
39
|
"type": "string",
|
|
40
|
-
"description": "Optional formula expression — same shape as canExecute in action DSL. Uses `[field]` syntax to reference the affected record. Single-line, evaluates to boolean. If omitted, trigger fires for every matching event."
|
|
40
|
+
"description": "Optional formula expression — same shape as canExecute in action DSL. Uses `[field]` syntax to reference the affected record. Single-line, evaluates to boolean. If omitted, trigger fires for every matching event. Reference navigation (`[ref].[target]`) is REJECTED here: the event-time evaluator only reads columns of the changed row, so a nav path would resolve to null and the trigger would misfire. Navigate inside the action instead, or denormalise the value onto the watched entity."
|
|
41
41
|
},
|
|
42
42
|
"action": {
|
|
43
43
|
"type": "string",
|
|
44
|
-
"description": "Action code to invoke. The
|
|
44
|
+
"description": "Action code to invoke. The trigger binds the affected record to the action as RECORD CONTEXT, so the action reads its columns with `[field]` (and its PK with `[pk_column]`) exactly like a UI-invoked action — no lookup needed. The action's `entityCode` MUST equal this trigger's `entity`, and its `executionMode` must not be `batch`; both are enforced at pack time. Cross-module references use the qualified form 'module.action'."
|
|
45
45
|
},
|
|
46
46
|
"params": {
|
|
47
47
|
"type": "object",
|
|
48
|
-
"description": "Optional
|
|
48
|
+
"description": "Optional STATIC parameters passed to the action at invocation time — the literal values written here, nothing else. Nothing is injected at runtime: there is no `record_id` param. The affected record arrives as record context (`[field]`), and its pre-change values as `old[field]` on update/delete/status_change events."
|
|
49
49
|
},
|
|
50
50
|
"async": {
|
|
51
51
|
"type": "boolean",
|
|
52
|
-
"default":
|
|
53
|
-
"description": "When true, the action runs in the background — the triggering transaction commits before the action starts. Recommended for slow actions (emails, external API calls); background failures are logged, not surfaced to the caller. When false, the action runs synchronously inside the same transaction as the triggering insert/update/delete: if the action fails (e.g. calls error()), the whole mutation rolls back and the error is returned to the caller. Keep async:false actions fast
|
|
52
|
+
"default": true,
|
|
53
|
+
"description": "When true (the default), the action runs in the background — the triggering transaction commits before the action starts. Recommended for slow actions (emails, external API calls); background failures are logged, not surfaced to the caller. When false, the action runs synchronously inside the same transaction as the triggering insert/update/delete: if the action fails (e.g. calls error()), the whole mutation rolls back and the error is returned to the caller — so an async:false trigger can undo the user's write. Keep async:false actions fast; they run on the request's transaction."
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"additionalProperties": false
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Install the dForge authoring skills into the Claude skills directory.
|
|
3
|
+
//
|
|
4
|
+
// The skills ship inside the npm tarball, but Claude Code only looks in
|
|
5
|
+
// <config>/skills — not node_modules — so they have to be copied out. There are
|
|
6
|
+
// four (a router plus three stage skills), and the router's directory also
|
|
7
|
+
// carries the shared references/ + examples/ the MCP server serves as dforge://
|
|
8
|
+
// resources, so hand-copying is error-prone.
|
|
9
|
+
//
|
|
10
|
+
// Written in Node rather than bash so it behaves identically on Windows
|
|
11
|
+
// (cmd.exe, PowerShell), macOS, Linux, Git Bash and WSL. The bash version this
|
|
12
|
+
// replaced could not run in cmd/PowerShell at all, and under WSL it resolved
|
|
13
|
+
// $HOME to the LINUX home — silently installing to /home/you/.claude while
|
|
14
|
+
// Claude Code on Windows read C:\Users\you\.claude.
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// node scripts/install-skills.mjs # from a local checkout
|
|
18
|
+
// node scripts/install-skills.mjs --from-npm # download the published version
|
|
19
|
+
// DEST=/some/where node scripts/install-skills.mjs
|
|
20
|
+
//
|
|
21
|
+
// npm run install-skills
|
|
22
|
+
// npm run install-skills -- --from-npm
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const SKILLS = [
|
|
31
|
+
["dforge-mcp-author", "router — start here; also holds the shared\n references/ and examples/ the MCP server serves"],
|
|
32
|
+
["dforge-module-design", "Phase 0 — identity, intake, design, validation"],
|
|
33
|
+
["dforge-module-build", "Phases 1-5 — entities, behavior, views, security"],
|
|
34
|
+
["dforge-module-ship", "Phase 6 — validate, pack, install-fix loop"],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const PKG = "@dforge-core/dforge-mcp";
|
|
38
|
+
const fromNpm = process.argv.includes("--from-npm");
|
|
39
|
+
|
|
40
|
+
// Piping into `head`/`less` closes our stdout mid-write; without this Node
|
|
41
|
+
// turns that into an unhandled EPIPE and dumps a stack trace over what is
|
|
42
|
+
// otherwise a successful install.
|
|
43
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
44
|
+
stream.on("error", (e) => {
|
|
45
|
+
if (e.code === "EPIPE") process.exit(0);
|
|
46
|
+
throw e;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where Claude Code looks for skills. `CLAUDE_CONFIG_DIR` wins when set;
|
|
52
|
+
* otherwise `<home>/.claude/skills`. os.homedir() is the point of this being a
|
|
53
|
+
* Node script — on Windows it's %USERPROFILE% regardless of which shell (or
|
|
54
|
+
* shell emulator) invoked us.
|
|
55
|
+
*/
|
|
56
|
+
function defaultDest() {
|
|
57
|
+
if (process.env.DEST) return process.env.DEST;
|
|
58
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
59
|
+
return path.join(configDir, "skills");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** npm is a .cmd shim on Windows, which spawn can only launch through a shell. */
|
|
63
|
+
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
64
|
+
|
|
65
|
+
function npm(args, opts = {}) {
|
|
66
|
+
const r = spawnSync(NPM, args, {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
shell: process.platform === "win32",
|
|
69
|
+
...opts,
|
|
70
|
+
});
|
|
71
|
+
if (r.error) {
|
|
72
|
+
throw new Error(`Failed to run \`npm ${args.join(" ")}\`: ${r.error.message}. Is npm on your PATH?`);
|
|
73
|
+
}
|
|
74
|
+
if (r.status !== 0) {
|
|
75
|
+
throw new Error(`\`npm ${args.join(" ")}\` failed (exit ${r.status}):\n${r.stderr || r.stdout}`);
|
|
76
|
+
}
|
|
77
|
+
return (r.stdout || "").trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Download + unpack the published tarball into a temp dir, returning its
|
|
82
|
+
* skills/ path. Uses `npm pack` (which fetches only this package, not its
|
|
83
|
+
* ~35 MB native CLI dependency) plus `tar`, which ships with Windows 10 1803+,
|
|
84
|
+
* macOS and every Linux.
|
|
85
|
+
*/
|
|
86
|
+
function fetchFromNpm(tmp) {
|
|
87
|
+
// Resolve the real latest version rather than relying on a CDN alias that
|
|
88
|
+
// caches for hours after a publish and would serve stale skills.
|
|
89
|
+
const version = npm(["view", PKG, "version"]);
|
|
90
|
+
process.stdout.write(`Fetching ${PKG}@${version} …\n`);
|
|
91
|
+
npm(["pack", `${PKG}@${version}`, "--pack-destination", tmp], { cwd: tmp });
|
|
92
|
+
|
|
93
|
+
const tarball = fs.readdirSync(tmp).find((f) => f.endsWith(".tgz"));
|
|
94
|
+
if (!tarball) throw new Error(`npm pack produced no .tgz in ${tmp}`);
|
|
95
|
+
|
|
96
|
+
const r = spawnSync("tar", ["-xzf", path.join(tmp, tarball), "-C", tmp], { encoding: "utf8" });
|
|
97
|
+
if (r.error || r.status !== 0) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Could not extract ${tarball}: ${r.error?.message ?? r.stderr}\n` +
|
|
100
|
+
"`tar` is required (built into Windows 10 1803+, macOS and Linux). " +
|
|
101
|
+
"Alternatively clone the repo and run this script without --from-npm.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return path.join(tmp, "package", "skills");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function main() {
|
|
108
|
+
const dest = defaultDest();
|
|
109
|
+
let tmp;
|
|
110
|
+
let src;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
if (fromNpm) {
|
|
114
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dforge-skills-"));
|
|
115
|
+
src = fetchFromNpm(tmp);
|
|
116
|
+
} else {
|
|
117
|
+
// Relative to this file, so it works from any cwd.
|
|
118
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
119
|
+
src = path.join(here, "..", "skills");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!fs.existsSync(src)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`No skills directory at ${src}.` +
|
|
125
|
+
(fromNpm ? "" : " Run with --from-npm to install from the published package instead."),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
130
|
+
let installed = 0;
|
|
131
|
+
for (const [skill] of SKILLS) {
|
|
132
|
+
const from = path.join(src, skill);
|
|
133
|
+
if (!fs.existsSync(from)) {
|
|
134
|
+
process.stderr.write(` ! skipping ${skill} (not found in ${src})\n`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Replace wholesale: a stale reference file left behind is worse than a
|
|
138
|
+
// missing one, because the agent will happily author against it.
|
|
139
|
+
fs.rmSync(path.join(dest, skill), { recursive: true, force: true });
|
|
140
|
+
fs.cpSync(from, path.join(dest, skill), { recursive: true });
|
|
141
|
+
process.stdout.write(` \u2713 ${skill}\n`);
|
|
142
|
+
installed++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (installed === 0) throw new Error(`No skills were installed from ${src}.`);
|
|
146
|
+
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
`\nInstalled ${installed} skill${installed === 1 ? "" : "s"} to ${dest}\n\n`,
|
|
149
|
+
);
|
|
150
|
+
for (const [skill, blurb] of SKILLS) {
|
|
151
|
+
process.stdout.write(` ${skill.padEnd(21)} ${blurb}\n`);
|
|
152
|
+
}
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
"\nRe-run after every dforge-mcp upgrade. The skill version is not checked at\n" +
|
|
155
|
+
"runtime, so stale skills against new tools will misroute calls.\n",
|
|
156
|
+
);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
process.stderr.write(`error: ${e.message}\n`);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
} finally {
|
|
161
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main();
|