@brainervirus/workit-core 0.7.0 → 0.8.0
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/README.md +24 -152
- package/package.json +1 -1
- package/scripts/install-cursor-plugin.sh +19 -23
- package/scripts/rewrite-workspace-deps.ts +2 -1
- package/scripts/run-cursor-mcp.sh +4 -8
- package/scripts/sync-runtime.sh +15 -2
- package/scripts/validate-cursor-marketplace.ts +217 -0
- package/src/core/doctor.ts +36 -15
- package/src/core/registration.ts +29 -30
- package/src/core/setup.ts +50 -33
- package/src/core/support-matrix.ts +1 -1
- package/src/core/sync-runtime.ts +19 -1
package/README.md
CHANGED
|
@@ -1,167 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brainervirus/workit-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared core for the workit plugins — the single source of workflow logic that the OpenCode, Cursor, and CLI packages adapt to their native surfaces.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
| --- | --- | --- |
|
|
7
|
-
| **OpenCode** | `packages/workit-opencode/src/plugin.ts` |  |
|
|
8
|
-
| **Cursor** | `packages/workit-cursor/` (MCP + hooks + rules + skills) |  |
|
|
9
|
-
| **Shared core** | `packages/workit-core/` (src, skills, commands, scripts, templates) |  |
|
|
10
|
-
| **CLI** | `packages/workit-cli/` (Ink wizard, bin `workit`) |  |
|
|
5
|
+
## When you need it directly
|
|
11
6
|
|
|
12
|
-
|
|
7
|
+
End users do **not** install this package directly — install `@brainervirus/workit-cli` (wizard) or the platform plugin (`@brainervirus/workit-opencode`, `@brainervirus/workit-cursor`). The adapters pull core in as a dependency and ship it with their bundles.
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
[](https://github.com/BrainerVirus/workit/actions)
|
|
16
|
-
[](LICENSE)
|
|
9
|
+
You consume `@brainervirus/workit-core` directly only when:
|
|
17
10
|
|
|
18
|
-
|
|
11
|
+
- Building a new host adapter (e.g. a future `codex/` adapter) that maps a host's native surfaces to the shared `src/core/` logic.
|
|
12
|
+
- Reading the canonical source of workflow behavior: specs/plans, approval flow, commit/PR, changelog, YouTrack, presentation, doctor, and diagnostics.
|
|
19
13
|
|
|
20
|
-
|
|
14
|
+
## Layout and ownership
|
|
21
15
|
|
|
22
|
-
|
|
16
|
+
| Path | Contents |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `src/core/` | Shared TS logic (setup, registration, doctor, logger, branch, PR, changelog, YouTrack, presentation, docs, SDD, support matrix). |
|
|
19
|
+
| `src/core.ts` | Package entry; adapters import `@brainervirus/workit-core/src/*`. |
|
|
20
|
+
| `skills/` | 12 OpenCode-native `wk-*` skills. |
|
|
21
|
+
| `commands/` | 12 OpenCode `wk-*` command prompts. |
|
|
22
|
+
| `scripts/` | Shared shell installers/launchers and the release-time workspace-dep rewrite. |
|
|
23
|
+
| `templates/` | Execution and Superpowers contract templates. |
|
|
24
|
+
| `vendor/superpowers/skills/` | 14 vendored Superpowers skills. |
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
npm i @brainervirus/workit-core # shared core (skills, commands, scripts)
|
|
26
|
-
npm i @brainervirus/workit-opencode # OpenCode plugin (thin over the core)
|
|
27
|
-
npm i @brainervirus/workit-cursor # Cursor plugin (MCP + hooks + rules)
|
|
28
|
-
npm i @brainervirus/workit-cli # interactive wizard
|
|
29
|
-
npx @brainervirus/workit-cli init
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
**Local development** — use the repo path instead; no package cache, disk is the source of truth:
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
bun i
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
**Manual setup (skip the wizard)** — configure each tool by hand; see [Usage](#usage) for the OpenCode plugin entry and Cursor MCP server.
|
|
39
|
-
|
|
40
|
-
## Usage
|
|
41
|
-
|
|
42
|
-
Manual setup for those who skipped the wizard (`npx @brainervirus/workit-cli init`):
|
|
43
|
-
|
|
44
|
-
**OpenCode** — reference the plugin entry in `opencode.json` / `opencode.jsonc` (`~/.config/opencode/opencode.json`):
|
|
45
|
-
|
|
46
|
-
```json
|
|
47
|
-
{
|
|
48
|
-
"$schema": "https://opencode.ai/config.json",
|
|
49
|
-
"plugin": ["@brainervirus/workit-opencode"]
|
|
50
|
-
}
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
Local dev variant (absolute path to this repo):
|
|
26
|
+
Adapters map host-native surfaces to `src/core/`; they never re-implement core logic. Keep shared behavior here and host-specific presentation in each adapter.
|
|
54
27
|
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
"$schema": "https://opencode.ai/config.json",
|
|
58
|
-
"plugin": ["file:///path/to/workit/packages/workit-opencode/src/plugin.ts"]
|
|
59
|
-
}
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
**Cursor** — the MCP server runs from the package's `cursor/` directory. Add to `.cursor/mcp.json`:
|
|
63
|
-
|
|
64
|
-
```json
|
|
65
|
-
{
|
|
66
|
-
"mcpServers": {
|
|
67
|
-
"workit": {
|
|
68
|
-
"command": "bun",
|
|
69
|
-
"args": ["run", "node_modules/@brainervirus/workit-cursor/mcp/server.ts"]
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
```
|
|
28
|
+
## Exports
|
|
74
29
|
|
|
75
|
-
|
|
30
|
+
- Main entry: `./src/core.ts`.
|
|
31
|
+
- Subpath exports: `./src/*.ts` (e.g. `@brainervirus/workit-core/src/core/doctor`), plus `./package.json`.
|
|
76
32
|
|
|
77
|
-
##
|
|
78
|
-
|
|
79
|
-
- **Bun ≥ 1.0** — runtime for the shared `src/core` logic, the OpenCode plugin, and the Cursor MCP server (`bun server.ts`). Install once:
|
|
33
|
+
## Package scripts
|
|
80
34
|
|
|
81
35
|
```bash
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
Then add to your shell profile (or rely on the MCP launcher's `~/.bun/bin/bun` fallback):
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
export PATH="$HOME/.bun/bin:$PATH"
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
- **Git** — branch resolution, SDD review diffs, and verify gates.
|
|
92
|
-
|
|
93
|
-
## Repo layout
|
|
94
|
-
|
|
36
|
+
bun run typecheck # tsc --noEmit
|
|
95
37
|
```
|
|
96
|
-
workit/
|
|
97
|
-
├── packages/
|
|
98
|
-
│ ├── workit-core/ # @brainervirus/workit-core — shared core
|
|
99
|
-
│ │ ├── src/ # core TS (src/core, src/tools, src/state)
|
|
100
|
-
│ │ ├── skills/ # OpenCode-native skills (wk-*)
|
|
101
|
-
│ │ ├── commands/ # OpenCode commands (wk-*)
|
|
102
|
-
│ │ ├── scripts/ # shared shell + installers (all logic in src/, TS via bun)
|
|
103
|
-
│ │ ├── templates/ # execution + superpowers contracts
|
|
104
|
-
│ │ └── vendor/ # vendored superpowers skills
|
|
105
|
-
│ ├── workit-opencode/ # @brainervirus/workit-opencode — OpenCode plugin (src/plugin.ts)
|
|
106
|
-
│ ├── workit-cursor/ # @brainervirus/workit-cursor — Cursor plugin (MCP, hooks, rules, skills)
|
|
107
|
-
│ └── workit-cli/ # @brainervirus/workit-cli — Ink setup wizard (bin: workit)
|
|
108
|
-
├── .github/workflows/ # CI + release
|
|
109
|
-
└── test/ # bun tests (OpenCode plugin)
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
## CI / local checks
|
|
113
|
-
|
|
114
|
-
```bash
|
|
115
|
-
bun run check # bun test + tsc + cursor MCP regressions
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
GitHub Actions:
|
|
119
|
-
|
|
120
|
-
- **CI** — on push/PR to `main`: matrix of 3 OS (ubuntu, macos, windows) running `actions/checkout@v7` + `oven-sh/setup-bun@v2`, then `bun install --frozen-lockfile` + `bun run check`
|
|
121
|
-
- **Release** — on push to `main`: [semantic-release](https://github.com/semantic-release/semantic-release) computes the next version from Conventional Commits, publishes the four workspaces to npm in dependency order — `workit-core`, `workit-opencode`, `workit-cursor`, `workit-cli` (with provenance), and creates the git tag + GitHub Release
|
|
122
|
-
|
|
123
|
-
No manual tags — semantic-release owns the version/tag flow:
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
git push origin main
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
## Quality gates
|
|
130
|
-
|
|
131
|
-
- **CI checks** — the per-package check jobs (workit-core/opencode/cursor/cli/shared) are required in branch protection; the PR cannot merge while any is red.
|
|
132
|
-
- **Subagent review** — `wk-implement` runs a two-stage review (spec compliance + code quality) per task with fresh `general` agents.
|
|
133
|
-
|
|
134
|
-
## Architecture
|
|
135
|
-
|
|
136
|
-
| Concern | OpenCode | Cursor |
|
|
137
|
-
| --- | --- | --- |
|
|
138
|
-
| Tools | native plugin | MCP server |
|
|
139
|
-
| Session contract | `messages.transform` | `sessionStart` hook |
|
|
140
|
-
| Handoff | spawns OpenCode session | handoff prompt |
|
|
141
|
-
| Shared logic | `scripts/` | `scripts/` via `WORKFLOW_TOOLKIT_ROOT` |
|
|
142
|
-
| Install root | GitHub plugin pin | `~/.local/share/workflow-toolkit` + local plugin copy |
|
|
143
|
-
|
|
144
|
-
## Future: Codex CLI
|
|
145
|
-
|
|
146
|
-
Add `codex/` adapter; reuse `scripts/` and `templates/`.
|
|
147
|
-
|
|
148
|
-
## Workflow docs layout
|
|
149
|
-
|
|
150
|
-
Features live in `docs/<slug>/`:
|
|
151
|
-
|
|
152
|
-
- `docs/<slug>/spec.md` and `docs/<slug>/plan.md` are **committed** (they travel with the branch).
|
|
153
|
-
- `docs/<slug>/sdd/` (progress ledger, `flow.json` approval state, briefs, review diffs) is **gitignored** — working state for the current cycle.
|
|
154
|
-
|
|
155
|
-
Consequences:
|
|
156
|
-
|
|
157
|
-
- A fresh clone starts every workflow at `draft` — the flow gates (`workflow_spec_approve` / `workflow_plan_approve` / `workflow_plan_menu`) must be re-run after checkout.
|
|
158
|
-
- The SDD state does not travel with the branch; spec/plan do.
|
|
159
|
-
|
|
160
|
-
## Contributing
|
|
161
|
-
|
|
162
|
-
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
163
|
-
|
|
164
|
-
## Kudos
|
|
165
38
|
|
|
166
|
-
|
|
167
|
-
- **Ponytail mode** — the lazy-engineer skill (ponytail, ponytail-review, ponytail-audit, …) by [Dietrich Gebert](https://github.com/dietrichgebert), active in this project's OpenCode config and installed as a shared skill.
|
|
39
|
+
Core has no build step — the adapters bundle it at build time. See the root [README](../../README.md) for the full development, CI, and versioning notes.
|
package/package.json
CHANGED
|
@@ -51,12 +51,11 @@ if [ -d "$SKILLS_DIR" ]; then
|
|
|
51
51
|
rm -f "$SKILLS_DIR"/wf-* "$SKILLS_DIR"/wk-*
|
|
52
52
|
fi
|
|
53
53
|
|
|
54
|
-
PLUGIN_DIR="$HOME/.cursor/plugins/local/
|
|
54
|
+
PLUGIN_DIR="$HOME/.cursor/plugins/local/workit"
|
|
55
55
|
REGISTRATION_TS="$ROOT/packages/workit-core/src/core/registration.ts"
|
|
56
56
|
CURSOR_SETTINGS="$HOME/.cursor/settings.json" CURSOR_MCP="$HOME/.cursor/mcp.json" PLUGIN_DIR="$PLUGIN_DIR" REGISTRATION_TS="$REGISTRATION_TS" bun -e '
|
|
57
57
|
import fs from "node:fs";
|
|
58
|
-
|
|
59
|
-
const { mergeCursorSettings, mergeCursorMcp, mergeCursorHooks, cursorMcpServerEntry } =
|
|
58
|
+
const { mergeCursorSettings, mergeCursorMcp, cursorMcpServerEntry } =
|
|
60
59
|
await import(process.env.REGISTRATION_TS!);
|
|
61
60
|
|
|
62
61
|
// RR-06: collapse current + legacy identities; never replace unrelated settings.
|
|
@@ -67,8 +66,8 @@ const settings = fs.existsSync(settingsPath)
|
|
|
67
66
|
const merged = mergeCursorSettings(settings, process.env.PLUGIN_DIR!);
|
|
68
67
|
fs.writeFileSync(settingsPath, JSON.stringify(merged.config, null, 2) + "\n");
|
|
69
68
|
|
|
70
|
-
// RR-06: one portable workit MCP registration
|
|
71
|
-
//
|
|
69
|
+
// RR-06/CA-17: one portable workit MCP registration via the published npx bin;
|
|
70
|
+
// stale "workflow-toolkit" entries removed.
|
|
72
71
|
const server = cursorMcpServerEntry(process.env.PLUGIN_DIR!);
|
|
73
72
|
const mcpPath = process.env.CURSOR_MCP!;
|
|
74
73
|
const mcp = fs.existsSync(mcpPath)
|
|
@@ -76,23 +75,6 @@ const mcp = fs.existsSync(mcpPath)
|
|
|
76
75
|
: { mcpServers: {} };
|
|
77
76
|
const mergedMcp = mergeCursorMcp(mcp, "workit", server);
|
|
78
77
|
fs.writeFileSync(mcpPath, JSON.stringify(mergedMcp.config, null, 2) + "\n");
|
|
79
|
-
|
|
80
|
-
// Keep the synced plugin manifests consistent with the current checkout state:
|
|
81
|
-
// only in a dist-less dev checkout, point them at the shims so Cursor still
|
|
82
|
-
// launches MCP/hooks (the shipped manifests already reference dist).
|
|
83
|
-
const distMcp = path.join(process.env.PLUGIN_DIR!, "dist", "mcp-server.js");
|
|
84
|
-
const pluginMcp = path.join(process.env.PLUGIN_DIR!, "mcp.json");
|
|
85
|
-
if (!fs.existsSync(distMcp) && fs.existsSync(pluginMcp)) {
|
|
86
|
-
const inPkg = JSON.parse(fs.readFileSync(pluginMcp, "utf8"));
|
|
87
|
-
const patched = mergeCursorMcp(inPkg, "workit", server);
|
|
88
|
-
fs.writeFileSync(pluginMcp, JSON.stringify(patched.config, null, 2) + "\n");
|
|
89
|
-
}
|
|
90
|
-
const pluginHooks = path.join(process.env.PLUGIN_DIR!, "hooks", "hooks-cursor.json");
|
|
91
|
-
if (!fs.existsSync(path.join(process.env.PLUGIN_DIR!, "dist", "cursor-session-start.js")) && fs.existsSync(pluginHooks)) {
|
|
92
|
-
const inPkg = JSON.parse(fs.readFileSync(pluginHooks, "utf8"));
|
|
93
|
-
const patched = mergeCursorHooks(inPkg, { command: "./hooks/session-start" });
|
|
94
|
-
fs.writeFileSync(pluginHooks, JSON.stringify(patched.config, null, 2) + "\n");
|
|
95
|
-
}
|
|
96
78
|
'
|
|
97
79
|
|
|
98
80
|
# DG-09: verify the just-written Cursor registration with the shared offline doctor.
|
|
@@ -101,6 +83,20 @@ if ! bun "$ROOT/packages/workit-core/scripts/doctor-check.ts" cursor; then
|
|
|
101
83
|
exit 1
|
|
102
84
|
fi
|
|
103
85
|
|
|
86
|
+
# CA-08/CA-09: migrate the legacy local plugin identity to `workit` only after
|
|
87
|
+
# the canonical registration succeeded; carry the legacy user rules forward first.
|
|
88
|
+
LEGACY_PLUGIN_DIR="${HOME}/.cursor/plugins/local/workflow-toolkit"
|
|
89
|
+
if [ -d "$LEGACY_PLUGIN_DIR" ] && [ "$LEGACY_PLUGIN_DIR" != "$PLUGIN_DIR" ]; then
|
|
90
|
+
if [ -d "$LEGACY_PLUGIN_DIR/rules" ]; then
|
|
91
|
+
mkdir -p "$PLUGIN_DIR/rules"
|
|
92
|
+
for rule in "$LEGACY_PLUGIN_DIR"/rules/*.mdc; do
|
|
93
|
+
[ -e "$rule" ] || continue
|
|
94
|
+
[ -e "$PLUGIN_DIR/rules/$(basename "$rule")" ] || cp "$rule" "$PLUGIN_DIR/rules/"
|
|
95
|
+
done
|
|
96
|
+
fi
|
|
97
|
+
rm -rf "$LEGACY_PLUGIN_DIR"
|
|
98
|
+
fi
|
|
99
|
+
|
|
104
100
|
echo "Cursor plugin installed + auto-sync enabled (sessionStart)."
|
|
105
101
|
echo "Share: $SHARE"
|
|
106
|
-
ls "$HOME/.cursor/plugins/local/
|
|
102
|
+
ls "$HOME/.cursor/plugins/local/workit/skills" | grep '^wk-' || true
|
|
@@ -24,9 +24,10 @@ for (const pkg of ["workit-opencode", "workit-cursor", "workit-cli"]) {
|
|
|
24
24
|
}
|
|
25
25
|
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
26
26
|
}
|
|
27
|
+
// The root marketplace index carries no release version; only the package
|
|
28
|
+
// plugin manifest is versioned at release time.
|
|
27
29
|
for (const file of [
|
|
28
30
|
resolve(root, "packages/workit-cursor/.cursor-plugin/plugin.json"),
|
|
29
|
-
resolve(root, "packages/workit-cursor/marketplace.json"),
|
|
30
31
|
]) {
|
|
31
32
|
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
32
33
|
data.version = core.version;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Stable entrypoint for Cursor MCP deeplink / ~/.cursor/mcp.json
|
|
3
|
-
#
|
|
2
|
+
# Stable entrypoint for Cursor MCP deeplink / ~/.cursor/mcp.json (CA-17).
|
|
3
|
+
# Launch the published package's MCP bin through npx, never a repo-relative
|
|
4
|
+
# dist or share clone; a startup/network failure surfaces via npx's nonzero exit.
|
|
4
5
|
set -euo pipefail
|
|
5
|
-
|
|
6
|
-
SHARE_MCP="${HOME}/.local/share/workflow-toolkit/packages/workit-cursor/mcp/run-server.sh"
|
|
7
|
-
if [ -x "$PLUGIN_MCP" ]; then
|
|
8
|
-
exec "$PLUGIN_MCP" "$@"
|
|
9
|
-
fi
|
|
10
|
-
exec "$SHARE_MCP" "$@"
|
|
6
|
+
exec npx -y --package=@brainervirus/workit-cursor@latest workit-cursor-mcp "$@"
|
package/scripts/sync-runtime.sh
CHANGED
|
@@ -7,7 +7,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
|
7
7
|
. "$SCRIPT_DIR/lib/config-dir.sh"
|
|
8
8
|
|
|
9
9
|
SHARE="${HOME}/.local/share/workflow-toolkit"
|
|
10
|
-
PLUGIN_DIR="${HOME}/.cursor/plugins/local/
|
|
10
|
+
PLUGIN_DIR="${HOME}/.cursor/plugins/local/workit"
|
|
11
11
|
OPENCODE_PLUGINS="${HOME}/.config/opencode/plugins"
|
|
12
12
|
DEV_DEFAULT="${HOME}/Documents/projects/personal/workflow-toolkit"
|
|
13
13
|
DEV="${WORKFLOW_TOOLKIT_DEV:-$DEV_DEFAULT}"
|
|
@@ -127,7 +127,20 @@ if [ -d "$CONFIG_RULES_DIR" ]; then
|
|
|
127
127
|
" >/dev/null 2>&1 || true
|
|
128
128
|
fi
|
|
129
129
|
printf '%s\n' "$SHARE/packages/workit-core" >"$PLUGIN_DIR/.workflow-toolkit-root"
|
|
130
|
-
|
|
130
|
+
|
|
131
|
+
# CA-08/CA-09: migrate the legacy local plugin identity to `workit` only after
|
|
132
|
+
# the canonical sync succeeded; carry the legacy user rules forward first.
|
|
133
|
+
LEGACY_DIR="${HOME}/.cursor/plugins/local/workflow-toolkit"
|
|
134
|
+
if [ -d "$LEGACY_DIR" ] && [ "$LEGACY_DIR" != "$PLUGIN_DIR" ]; then
|
|
135
|
+
if [ -d "$LEGACY_DIR/rules" ]; then
|
|
136
|
+
mkdir -p "$PLUGIN_DIR/rules"
|
|
137
|
+
for rule in "$LEGACY_DIR"/rules/*.mdc; do
|
|
138
|
+
[ -e "$rule" ] || continue
|
|
139
|
+
[ -e "$PLUGIN_DIR/rules/$(basename "$rule")" ] || cp "$rule" "$PLUGIN_DIR/rules/"
|
|
140
|
+
done
|
|
141
|
+
fi
|
|
142
|
+
rm -rf "$LEGACY_DIR"
|
|
143
|
+
fi
|
|
131
144
|
|
|
132
145
|
# Remove broken TLA live-loader if present (OpenCode ignored it; /wk-* vanished)
|
|
133
146
|
rm -f "${OPENCODE_PLUGINS}/workflow-toolkit.ts"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Cursor Marketplace validator (Task 9, CA-13/CA-15/CA-17/CA-21): validates the
|
|
3
|
+
// tracked Marketplace artifact against the official Cursor JSON schemas plus the
|
|
4
|
+
// repository-specific invariants that a JSON Schema cannot express. Runs from a
|
|
5
|
+
// clean `git ls-files` checkout — it never reads dist/ or other ignored build
|
|
6
|
+
// output, so `bun run validate:cursor-marketplace` is a truthful clean-checkout
|
|
7
|
+
// gate for CI.
|
|
8
|
+
//
|
|
9
|
+
// Official schema snapshots (test/fixtures/cursor-schemas/) are verbatim copies
|
|
10
|
+
// of the current official schemas, retrieved 2026-08-13 from:
|
|
11
|
+
// - https://raw.githubusercontent.com/cursor/plugins/main/schemas/plugin.schema.json
|
|
12
|
+
// - https://raw.githubusercontent.com/cursor/plugins/main/schemas/marketplace.schema.json
|
|
13
|
+
//
|
|
14
|
+
// `source` path resolution (Task 7 carry-forward, RESOLVED): the official
|
|
15
|
+
// marketplace.schema.json documents `plugins[].source` as "Path to the plugin
|
|
16
|
+
// directory (relative to the marketplace root)". The marketplace root is the
|
|
17
|
+
// directory that CONTAINS `.cursor-plugin/marketplace.json` — the repo root —
|
|
18
|
+
// NOT `.cursor-plugin/` itself. Evidence: the official cursor/plugins repo keeps
|
|
19
|
+
// `.cursor-plugin/marketplace.json` at the repo root and lists sources such as
|
|
20
|
+
// `continual-learning` and `third_party/gmail` with no `../` prefix, even though
|
|
21
|
+
// `.cursor-plugin/` is a sibling of those directories. Therefore
|
|
22
|
+
// `source: "packages/workit-cursor"` is correct and kept verbatim.
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdtempSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
readdirSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import Ajv from "ajv";
|
|
34
|
+
import addFormats from "ajv-formats";
|
|
35
|
+
import { CANONICAL_SKILLS, validateSkillManifests } from "../src/core/skill-manifests";
|
|
36
|
+
import { copySanitizedVendor } from "./vendor-assets";
|
|
37
|
+
|
|
38
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const repoRoot = path.resolve(scriptDir, "..", "..", "..");
|
|
40
|
+
const root = process.argv[2] ? path.resolve(process.argv[2]) : repoRoot;
|
|
41
|
+
|
|
42
|
+
const MARKETPLACE_REL = ".cursor-plugin/marketplace.json";
|
|
43
|
+
|
|
44
|
+
const frontmatterKeys = (file: string): Set<string> => {
|
|
45
|
+
const head = readFileSync(file, "utf8").slice(0, 4096);
|
|
46
|
+
const keys = new Set<string>();
|
|
47
|
+
if (!head.startsWith("---")) return keys;
|
|
48
|
+
const end = head.indexOf("\n---", 3);
|
|
49
|
+
if (end === -1) return keys;
|
|
50
|
+
for (const line of head.slice(3, end).split("\n")) {
|
|
51
|
+
const m = /^([A-Za-z0-9_-]+):/.exec(line);
|
|
52
|
+
if (m) keys.add(m[1]);
|
|
53
|
+
}
|
|
54
|
+
return keys;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const treesEqual = (a: string, b: string): string[] => {
|
|
58
|
+
const diffs: string[] = [];
|
|
59
|
+
// Thread the tree root so each file is keyed by its ROOT-relative path —
|
|
60
|
+
// otherwise every `SKILL.md` collapses to one map entry and only the
|
|
61
|
+
// last-read (readdir-order-dependent) skill is actually compared.
|
|
62
|
+
const walk = (rootDir: string, dir: string, into: Map<string, string>): void => {
|
|
63
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
const p = path.join(dir, entry.name);
|
|
65
|
+
if (entry.isDirectory()) walk(rootDir, p, into);
|
|
66
|
+
else into.set(path.relative(rootDir, p).split(path.sep).join("/"), readFileSync(p, "utf8"));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const left = new Map<string, string>();
|
|
70
|
+
const right = new Map<string, string>();
|
|
71
|
+
walk(a, a, left);
|
|
72
|
+
walk(b, b, right);
|
|
73
|
+
for (const [rel, content] of left) {
|
|
74
|
+
if (!right.has(rel)) diffs.push(`missing in rebuilt: ${rel}`);
|
|
75
|
+
else if (right.get(rel) !== content) diffs.push(`content drift: ${rel}`);
|
|
76
|
+
}
|
|
77
|
+
for (const rel of right.keys()) if (!left.has(rel)) diffs.push(`extra in rebuilt: ${rel}`);
|
|
78
|
+
return diffs;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const validateMarketplace = (rootArg: string): string[] => {
|
|
82
|
+
const errors: string[] = [];
|
|
83
|
+
|
|
84
|
+
if (!existsSync(path.join(rootArg, MARKETPLACE_REL))) {
|
|
85
|
+
errors.push(`missing ${MARKETPLACE_REL}`);
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 1. Official JSON Schema evaluation (AJV + ajv-formats), verbatim snapshots.
|
|
90
|
+
const validate = new Ajv({ strict: true, allErrors: true });
|
|
91
|
+
addFormats(validate);
|
|
92
|
+
const schemaDir = path.join(rootArg, "test/fixtures/cursor-schemas");
|
|
93
|
+
const pluginSchema = JSON.parse(
|
|
94
|
+
readFileSync(path.join(schemaDir, "plugin.schema.json"), "utf8"),
|
|
95
|
+
);
|
|
96
|
+
const marketSchema = JSON.parse(
|
|
97
|
+
readFileSync(path.join(schemaDir, "marketplace.schema.json"), "utf8"),
|
|
98
|
+
);
|
|
99
|
+
const market = JSON.parse(readFileSync(path.join(rootArg, MARKETPLACE_REL), "utf8"));
|
|
100
|
+
if (!validate.validate(marketSchema, market)) {
|
|
101
|
+
errors.push(
|
|
102
|
+
`marketplace.json invalid: ${(validate.errors ?? []).map((e) => e.message).join("; ")}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 2. Resolve each plugin source relative to the repo root; name must match.
|
|
107
|
+
for (const entry of (market.plugins ?? []) as { name: string; source: string }[]) {
|
|
108
|
+
const pluginDir = path.join(rootArg, entry.source);
|
|
109
|
+
const manifestRel = path.join(entry.source, ".cursor-plugin/plugin.json");
|
|
110
|
+
if (!existsSync(path.join(pluginDir, ".cursor-plugin/plugin.json"))) {
|
|
111
|
+
errors.push(`plugin ${entry.name}: source ${entry.source} has no .cursor-plugin/plugin.json`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const plugin = JSON.parse(readFileSync(path.join(pluginDir, ".cursor-plugin/plugin.json"), "utf8"));
|
|
115
|
+
if (!validate.validate(pluginSchema, plugin)) {
|
|
116
|
+
errors.push(
|
|
117
|
+
`plugin ${entry.name} plugin.json invalid: ${(validate.errors ?? []).map((e) => e.message).join("; ")}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (plugin.name !== entry.name) {
|
|
121
|
+
errors.push(`plugin ${entry.name}: plugin.json name ${plugin.name} does not match index`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3. Component paths resolve inside the plugin root (no `..`, no absolute).
|
|
125
|
+
// `skills`/`rules` are path-like (string or string[]). `mcpServers`/`hooks`
|
|
126
|
+
// may also be inline objects or arrays of objects, which the JSON-schema
|
|
127
|
+
// check already validates — only string/string[] values get the
|
|
128
|
+
// path-escape/existence check; non-string forms are skipped gracefully.
|
|
129
|
+
for (const field of ["skills", "rules", "mcpServers", "hooks"] as const) {
|
|
130
|
+
const value = plugin[field];
|
|
131
|
+
const rels: string[] =
|
|
132
|
+
typeof value === "string"
|
|
133
|
+
? [value]
|
|
134
|
+
: Array.isArray(value)
|
|
135
|
+
? value.filter((v): v is string => typeof v === "string")
|
|
136
|
+
: [];
|
|
137
|
+
for (const rel of rels) {
|
|
138
|
+
if (rel.includes("..") || rel.startsWith("/")) {
|
|
139
|
+
errors.push(`plugin ${entry.name}: ${field} path escapes root: ${rel}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!existsSync(path.join(pluginDir, rel))) {
|
|
143
|
+
errors.push(`plugin ${entry.name}: ${field} path missing: ${rel}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 4. Logo resolves inside the plugin root and exists.
|
|
149
|
+
const logo = plugin.logo as string | undefined;
|
|
150
|
+
if (logo && (logo.includes("..") || logo.startsWith("/"))) {
|
|
151
|
+
errors.push(`plugin ${entry.name}: logo path escapes root: ${logo}`);
|
|
152
|
+
} else if (logo && !existsSync(path.join(pluginDir, logo))) {
|
|
153
|
+
errors.push(`plugin ${entry.name}: logo missing: ${logo}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 5. Skills and rules carry valid frontmatter.
|
|
157
|
+
const skillRoots: [string, readonly string[]][] = [
|
|
158
|
+
[path.join(pluginDir, "skills"), CANONICAL_SKILLS.workit],
|
|
159
|
+
[path.join(pluginDir, "vendor/superpowers/skills"), CANONICAL_SKILLS.superpowers],
|
|
160
|
+
];
|
|
161
|
+
for (const [dir, expected] of skillRoots) {
|
|
162
|
+
const mismatch = validateSkillManifests(dir, expected, "skills");
|
|
163
|
+
if (mismatch) errors.push(`plugin ${entry.name}: ${mismatch}`);
|
|
164
|
+
for (const skill of expected) {
|
|
165
|
+
const keys = frontmatterKeys(path.join(dir, skill, "SKILL.md"));
|
|
166
|
+
if (!keys.has("name")) errors.push(`plugin ${entry.name}: ${skill}/SKILL.md missing frontmatter name`);
|
|
167
|
+
if (!keys.has("description")) errors.push(`plugin ${entry.name}: ${skill}/SKILL.md missing frontmatter description`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const rulesDir = path.join(pluginDir, "rules");
|
|
171
|
+
if (existsSync(rulesDir)) {
|
|
172
|
+
for (const rule of readdirSync(rulesDir).filter((f) => f.endsWith(".mdc"))) {
|
|
173
|
+
const keys = frontmatterKeys(path.join(rulesDir, rule));
|
|
174
|
+
if (!keys.has("description")) errors.push(`plugin ${entry.name}: ${rule} missing frontmatter description`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 6. No active runtime path targets an ignored dist file.
|
|
179
|
+
for (const rel of ["mcp.json", "hooks/hooks-cursor.json"]) {
|
|
180
|
+
const p = path.join(pluginDir, rel);
|
|
181
|
+
if (!existsSync(p)) continue;
|
|
182
|
+
const raw = readFileSync(p, "utf8");
|
|
183
|
+
if (raw.includes("dist/") || raw.includes("run-server") || raw.includes("session-start.js")) {
|
|
184
|
+
errors.push(`plugin ${entry.name}: ${rel} references ignored runtime output`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 7. Rebuilding the sanitized vendor tree yields no diff.
|
|
189
|
+
const sourceVendor = path.join(rootArg, "packages/workit-core/vendor/superpowers/skills");
|
|
190
|
+
const trackedVendor = path.join(pluginDir, "vendor/superpowers/skills");
|
|
191
|
+
if (existsSync(sourceVendor) && existsSync(trackedVendor)) {
|
|
192
|
+
const rebuilt = mkdtempSync(path.join(os.tmpdir(), "wk-vendor-rebuild-"));
|
|
193
|
+
try {
|
|
194
|
+
copySanitizedVendor(sourceVendor, rebuilt);
|
|
195
|
+
for (const diff of treesEqual(rebuilt, trackedVendor)) {
|
|
196
|
+
errors.push(`plugin ${entry.name}: vendor drift: ${diff}`);
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
rmSync(rebuilt, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
errors.push(`plugin ${entry.name}: missing vendor source or tracked tree`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return errors;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (import.meta.main) {
|
|
210
|
+
const errors = validateMarketplace(root);
|
|
211
|
+
if (errors.length > 0) {
|
|
212
|
+
for (const e of errors) process.stderr.write(`${e}\n`);
|
|
213
|
+
process.stderr.write(`marketplace validation FAILED (${errors.length} error${errors.length === 1 ? "" : "s"})\n`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
console.log(`marketplace validation passed (root: ${root})`);
|
|
217
|
+
}
|
package/src/core/doctor.ts
CHANGED
|
@@ -125,7 +125,7 @@ const resolve = (options: DoctorOptions): Resolved => {
|
|
|
125
125
|
cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
|
|
126
126
|
cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
|
|
127
127
|
cursorPluginDir:
|
|
128
|
-
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "
|
|
128
|
+
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
|
|
129
129
|
env,
|
|
130
130
|
installer: options.installer ?? false,
|
|
131
131
|
};
|
|
@@ -323,7 +323,6 @@ const assetPathsFor = (host: DoctorHost, dev: string): string[] => {
|
|
|
323
323
|
return [
|
|
324
324
|
path.join(pkg, "assets", "templates", "spec-template.md"),
|
|
325
325
|
path.join(pkg, "mcp.json"),
|
|
326
|
-
path.join(pkg, "marketplace.json"),
|
|
327
326
|
path.join(pkg, ".cursor-plugin"),
|
|
328
327
|
];
|
|
329
328
|
case "cli":
|
|
@@ -377,12 +376,10 @@ const launcherSlotsFor = (host: DoctorHost, dev: string): string[][] => {
|
|
|
377
376
|
case "opencode":
|
|
378
377
|
return [[path.join(pkg, "src", "plugin.ts"), path.join(pkg, "dist", "plugin.js")]];
|
|
379
378
|
case "cursor":
|
|
379
|
+
// The dist entries are the npm bin targets the npx launcher executes.
|
|
380
380
|
return [
|
|
381
|
-
[path.join(pkg, "dist", "mcp-server.js")
|
|
382
|
-
[
|
|
383
|
-
path.join(pkg, "dist", "cursor-session-start.js"),
|
|
384
|
-
path.join(pkg, "hooks", "session-start"),
|
|
385
|
-
],
|
|
381
|
+
[path.join(pkg, "dist", "mcp-server.js")],
|
|
382
|
+
[path.join(pkg, "dist", "cursor-session-start.js")],
|
|
386
383
|
];
|
|
387
384
|
case "cli":
|
|
388
385
|
return [[path.join(pkg, "src", "index.tsx"), path.join(pkg, "dist", "index.js")]];
|
|
@@ -405,9 +402,9 @@ const validNodeEntry = (entry: string, runtime: string, env: NodeJS.ProcessEnv):
|
|
|
405
402
|
}
|
|
406
403
|
};
|
|
407
404
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
405
|
+
type CursorLauncher = { kind: "node"; runtime: string; entry: string } | { kind: "npx" };
|
|
406
|
+
|
|
407
|
+
const registeredCursorLauncher = (res: Resolved): CursorLauncher | null | "invalid" => {
|
|
411
408
|
if (!existsSync(res.cursorMcp)) return "invalid";
|
|
412
409
|
const config = readJson(res.cursorMcp);
|
|
413
410
|
if (!config) return null; // malformed_config owns malformed JSON/object reporting
|
|
@@ -419,8 +416,19 @@ const registeredCursorLauncher = (
|
|
|
419
416
|
return "invalid";
|
|
420
417
|
}
|
|
421
418
|
const executable = path.basename(command).toLowerCase();
|
|
419
|
+
// CA-17: the canonical launcher runs the published package through npx; the
|
|
420
|
+
// offline doctor validates its shape (never the registry reachability).
|
|
421
|
+
if (executable === "npx" || executable === "npx.exe" || executable === "npx.cmd") {
|
|
422
|
+
// CA-17: exact positional tokens — a substring match would accept
|
|
423
|
+
// `@latest-alpha` or `workit-cursor-mcp-foo`.
|
|
424
|
+
if (args[0] !== "-y") return "invalid";
|
|
425
|
+
if (args[1] !== "--package=@brainervirus/workit-cursor@latest") return "invalid";
|
|
426
|
+
if (args[2] !== "workit-cursor-mcp") return "invalid";
|
|
427
|
+
return { kind: "npx" };
|
|
428
|
+
}
|
|
422
429
|
if (executable !== "node" && executable !== "node.exe") return "invalid";
|
|
423
430
|
return {
|
|
431
|
+
kind: "node",
|
|
424
432
|
runtime: command,
|
|
425
433
|
entry: path.isAbsolute(args[0]) ? args[0] : path.resolve(path.dirname(res.cursorMcp), args[0]),
|
|
426
434
|
};
|
|
@@ -430,7 +438,10 @@ const checkLauncher = (res: Resolved): DoctorCheck => {
|
|
|
430
438
|
const dev = res.dev;
|
|
431
439
|
const hosts = hostsFor(res.host);
|
|
432
440
|
const registered = hosts.includes("cursor") ? registeredCursorLauncher(res) : null;
|
|
433
|
-
const runtime =
|
|
441
|
+
const runtime =
|
|
442
|
+
registered && registered !== "invalid" && registered.kind === "node"
|
|
443
|
+
? registered.runtime
|
|
444
|
+
: "node";
|
|
434
445
|
const missing = hosts.includes("cursor")
|
|
435
446
|
? ["dist/mcp-server.js", "dist/cursor-session-start.js"]
|
|
436
447
|
.map((rel) => path.join(res.cursorPluginDir, rel))
|
|
@@ -440,7 +451,11 @@ const checkLauncher = (res: Resolved): DoctorCheck => {
|
|
|
440
451
|
if (hosts.includes("cursor")) {
|
|
441
452
|
if (registered === "invalid") {
|
|
442
453
|
missing.push(`cursor: canonical workit MCP launcher in ${res.cursorMcp}`);
|
|
443
|
-
} else if (
|
|
454
|
+
} else if (
|
|
455
|
+
registered &&
|
|
456
|
+
registered.kind === "node" &&
|
|
457
|
+
!validNodeEntry(registered.entry, registered.runtime, res.env)
|
|
458
|
+
) {
|
|
444
459
|
missing.push(`cursor: registered ${registered.entry}`);
|
|
445
460
|
}
|
|
446
461
|
}
|
|
@@ -569,9 +584,15 @@ const checkDuplicateRegistration = (res: Resolved): DoctorCheck => {
|
|
|
569
584
|
);
|
|
570
585
|
}
|
|
571
586
|
const dirs = Array.isArray(settings?.plugin_dirs)
|
|
572
|
-
? settings.plugin_dirs
|
|
573
|
-
|
|
574
|
-
|
|
587
|
+
? settings.plugin_dirs.map(String).filter((d) => {
|
|
588
|
+
// Exact local plugin-dir identities only (CA-09): a similarly-named
|
|
589
|
+
// unrelated dir (e.g. `local/workflow-toolkit-extra`) is preserved
|
|
590
|
+
// and must never be counted as a Workit entry.
|
|
591
|
+
const n = d.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
592
|
+
return (
|
|
593
|
+
isWorkitPlugin(d) || n.endsWith("local/workit") || n.endsWith("local/workflow-toolkit")
|
|
594
|
+
);
|
|
595
|
+
})
|
|
575
596
|
: [];
|
|
576
597
|
if (dirs.length > 1) problems.push(`cursor plugin_dirs has ${dirs.length} workit entries`);
|
|
577
598
|
}
|
package/src/core/registration.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// never rewritten — their values round-trip JSON-identical. The install
|
|
5
5
|
// scripts (`packages/workit-core/scripts/install-*-plugin.sh`) import these so
|
|
6
6
|
// there is exactly one source of truth for registration merging.
|
|
7
|
-
import { existsSync } from "node:fs";
|
|
8
7
|
import path from "node:path";
|
|
9
8
|
|
|
10
9
|
export interface MergeResult<T> {
|
|
@@ -37,6 +36,8 @@ export function isWorkitPlugin(value: unknown): boolean {
|
|
|
37
36
|
named(s, "workflow-toolkit") ||
|
|
38
37
|
named(s, "workflow-toolkit-opencode") ||
|
|
39
38
|
named(s, "local/workflow-toolkit") ||
|
|
39
|
+
named(s, "workit") ||
|
|
40
|
+
named(s, "local/workit") ||
|
|
40
41
|
named(s, "@brainervirus/workit-opencode") ||
|
|
41
42
|
named(s, "@brainervirus/workit-cursor") ||
|
|
42
43
|
(url && pkgPath) ||
|
|
@@ -84,16 +85,17 @@ export function mergeOpenCodeConfig(
|
|
|
84
85
|
return { config: base, changed };
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
/** Collapse current + legacy Cursor plugin identities to
|
|
88
|
+
/** Collapse current + legacy Cursor plugin identities to the canonical `workit`. */
|
|
88
89
|
export function mergeCursorEnabledPlugins(enabled: unknown): MergeResult<Record<string, boolean>> {
|
|
89
90
|
const prev = isRecord(enabled) ? { ...(enabled as Record<string, boolean>) } : {};
|
|
90
|
-
const next: Record<string, boolean> = { ...prev,
|
|
91
|
+
const next: Record<string, boolean> = { ...prev, workit: true };
|
|
92
|
+
delete next["workflow-toolkit"]; // legacy identity
|
|
91
93
|
delete next["local/workflow-toolkit"]; // legacy duplicate identity
|
|
92
94
|
const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["enabled_plugins"] : [];
|
|
93
95
|
return { config: next, changed };
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
/** Append the plugin dir once,
|
|
98
|
+
/** Append the plugin dir once, dropping the exact legacy sibling directory. */
|
|
97
99
|
export function mergeCursorPluginDirs(
|
|
98
100
|
pluginDirs: unknown,
|
|
99
101
|
pluginDir: string,
|
|
@@ -105,13 +107,17 @@ export function mergeCursorPluginDirs(
|
|
|
105
107
|
return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
|
|
106
108
|
};
|
|
107
109
|
const normalized = strip(pluginDir);
|
|
110
|
+
// CA-08: the legacy local plugin dir is the exact sibling of the canonical
|
|
111
|
+
// dir; remove only that entry, never a similarly-named unrelated dir (D3).
|
|
112
|
+
const legacy = strip(path.join(path.dirname(pluginDir), "workflow-toolkit"));
|
|
108
113
|
const prev = Array.isArray(pluginDirs) ? pluginDirs.map(String) : [];
|
|
114
|
+
const kept = prev.filter((d) => strip(d) !== legacy);
|
|
109
115
|
// Normalize both sides for comparison so a trailing-slash variant of an
|
|
110
116
|
// existing entry is not appended as a duplicate; existing entries are kept
|
|
111
117
|
// verbatim.
|
|
112
|
-
const exists =
|
|
113
|
-
const next = exists ?
|
|
114
|
-
const changed = next
|
|
118
|
+
const exists = kept.some((d) => strip(d) === normalized);
|
|
119
|
+
const next = exists ? kept : [...kept, normalized];
|
|
120
|
+
const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["plugin_dirs"] : [];
|
|
115
121
|
return { config: next, changed };
|
|
116
122
|
}
|
|
117
123
|
|
|
@@ -174,42 +180,35 @@ export function mergeCursorHooks(
|
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
/**
|
|
177
|
-
* Portable Cursor MCP server entry
|
|
178
|
-
*
|
|
179
|
-
*
|
|
183
|
+
* Portable Cursor MCP server entry (CA-16/CA-17): launch the published package
|
|
184
|
+
* through npx against its npm bin, so the Marketplace plugin never depends on a
|
|
185
|
+
* repo-relative or untracked dist file.
|
|
180
186
|
*/
|
|
181
|
-
export function cursorMcpServerEntry(
|
|
187
|
+
export function cursorMcpServerEntry(_packageDir: string): {
|
|
182
188
|
command: string;
|
|
183
189
|
args: string[];
|
|
184
190
|
} {
|
|
185
|
-
if (existsSync(path.join(packageDir, "dist", "mcp-server.js"))) {
|
|
186
|
-
return {
|
|
187
|
-
command: "node",
|
|
188
|
-
args: [path.join(packageDir, "dist", "mcp-server.js"), "${workspaceFolder}"],
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
191
|
return {
|
|
192
|
-
command: "
|
|
193
|
-
args: [
|
|
192
|
+
command: "npx",
|
|
193
|
+
args: [
|
|
194
|
+
"-y",
|
|
195
|
+
"--package=@brainervirus/workit-cursor@latest",
|
|
196
|
+
"workit-cursor-mcp",
|
|
197
|
+
"${workspaceFolder}",
|
|
198
|
+
],
|
|
194
199
|
};
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
/**
|
|
198
|
-
* Portable Cursor sessionStart hook entry:
|
|
199
|
-
*
|
|
203
|
+
* Portable Cursor sessionStart hook entry (CA-17): a single command string in
|
|
204
|
+
* Cursor's documented format (no args array).
|
|
200
205
|
*/
|
|
201
|
-
export function cursorHooksEntry(
|
|
206
|
+
export function cursorHooksEntry(_packageDir: string): {
|
|
202
207
|
command: string;
|
|
203
208
|
args: string[];
|
|
204
209
|
} {
|
|
205
|
-
if (existsSync(path.join(packageDir, "dist", "cursor-session-start.js"))) {
|
|
206
|
-
return {
|
|
207
|
-
command: "node",
|
|
208
|
-
args: [path.join(packageDir, "dist", "cursor-session-start.js")],
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
210
|
return {
|
|
212
|
-
command: "
|
|
213
|
-
args: [
|
|
211
|
+
command: "npx -y --package=@brainervirus/workit-cursor@latest workit-cursor-session-start",
|
|
212
|
+
args: [],
|
|
214
213
|
};
|
|
215
214
|
}
|
package/src/core/setup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
copyFileSync,
|
|
3
3
|
cpSync,
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
@@ -505,7 +505,7 @@ const resolveSetupPaths = (
|
|
|
505
505
|
cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
|
|
506
506
|
cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
|
|
507
507
|
cursorPluginDir:
|
|
508
|
-
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "
|
|
508
|
+
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
|
|
509
509
|
};
|
|
510
510
|
};
|
|
511
511
|
|
|
@@ -804,22 +804,14 @@ function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
|
|
|
804
804
|
};
|
|
805
805
|
}
|
|
806
806
|
|
|
807
|
-
// Mirror the sync-runtime plugin mirror: copy the package (minus node_modules)
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// The installed plugin's mcp.json is a derived artifact: the shipped manifest
|
|
812
|
-
// stays package-relative (PT-10), but Cursor spawns plugin MCP servers with
|
|
813
|
-
// the workspace as cwd, so the installed copy must carry an absolute entry.
|
|
814
|
-
const cursorMcpManifest = (dir: string): string =>
|
|
815
|
-
JSON.stringify({ mcpServers: { workit: cursorMcpServerEntry(dir) } }, null, 2) + "\n";
|
|
816
|
-
|
|
807
|
+
// Mirror the sync-runtime plugin mirror: copy the package (minus node_modules)
|
|
808
|
+
// and write the same `.workflow-toolkit-root` marker so a re-sync from the same
|
|
809
|
+
// source is a truthful Skipped. The shipped mcp.json is the Marketplace-safe
|
|
810
|
+
// npx command (CA-17), so it is copied verbatim — no absolute dist derivation.
|
|
817
811
|
const samePluginContent = (src: string, dest: string, relative = ""): boolean => {
|
|
818
812
|
try {
|
|
819
813
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
820
814
|
if (entry.name === "node_modules") continue;
|
|
821
|
-
// mcp.json is derived at install time (cursorMcpManifest), not copied.
|
|
822
|
-
if (relative === "" && entry.name === "mcp.json") continue;
|
|
823
815
|
const source = path.join(src, entry.name);
|
|
824
816
|
const installed = path.join(dest, entry.name);
|
|
825
817
|
if (entry.isDirectory()) {
|
|
@@ -837,7 +829,6 @@ const samePluginContent = (src: string, dest: string, relative = ""): boolean =>
|
|
|
837
829
|
const rel = path.join(relative, entry.name);
|
|
838
830
|
if (rel === ".workflow-toolkit-root") continue;
|
|
839
831
|
if (relative === "rules" && entry.isFile() && entry.name.endsWith(".mdc")) continue;
|
|
840
|
-
if (relative === "" && entry.name === "mcp.json") continue;
|
|
841
832
|
return false;
|
|
842
833
|
}
|
|
843
834
|
return true;
|
|
@@ -864,12 +855,7 @@ const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> =>
|
|
|
864
855
|
|
|
865
856
|
function copyPluginDir(src: string, dest: string): SetupResultStatus {
|
|
866
857
|
const marker = path.join(dest, ".workflow-toolkit-root");
|
|
867
|
-
const synced =
|
|
868
|
-
readFileSafe(marker)?.trim() === src &&
|
|
869
|
-
samePluginContent(src, dest) &&
|
|
870
|
-
// The mcp.json equality makes the Skipped verdict truthful: the derived
|
|
871
|
-
// manifest on disk must be the one this install would write.
|
|
872
|
-
readFileSafe(path.join(dest, "mcp.json")) === cursorMcpManifest(dest);
|
|
858
|
+
const synced = readFileSafe(marker)?.trim() === src && samePluginContent(src, dest);
|
|
873
859
|
if (synced) return "Skipped";
|
|
874
860
|
const hadDir = existsSync(dest);
|
|
875
861
|
const rules = preservedCursorRules(src, dest);
|
|
@@ -888,13 +874,6 @@ function copyPluginDir(src: string, dest: string): SetupResultStatus {
|
|
|
888
874
|
writeFileSync(path.join(stage, "rules", name), content);
|
|
889
875
|
}
|
|
890
876
|
writeFileSync(path.join(stage, ".workflow-toolkit-root"), src + "\n", "utf8");
|
|
891
|
-
for (const rel of ["hooks/session-start", "mcp/run-server.sh"]) {
|
|
892
|
-
try {
|
|
893
|
-
chmodSync(path.join(stage, rel), 0o755);
|
|
894
|
-
} catch {
|
|
895
|
-
/* optional launcher */
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
877
|
if (!samePluginContent(src, stage)) throw new Error("staged adapter content is incomplete");
|
|
899
878
|
if (hadDir) renameSync(dest, backup);
|
|
900
879
|
try {
|
|
@@ -903,10 +882,6 @@ function copyPluginDir(src: string, dest: string): SetupResultStatus {
|
|
|
903
882
|
if (hadDir && !existsSync(dest)) renameSync(backup, dest);
|
|
904
883
|
throw error;
|
|
905
884
|
}
|
|
906
|
-
// Derive the installed manifest against the FINAL installed path: the
|
|
907
|
-
// stage path dies with the swap dir, and the entry's dist check needs the
|
|
908
|
-
// live plugin dir to exist.
|
|
909
|
-
writeFileSync(path.join(dest, "mcp.json"), cursorMcpManifest(dest), "utf8");
|
|
910
885
|
rmSync(backup, { recursive: true, force: true });
|
|
911
886
|
} finally {
|
|
912
887
|
rmSync(swap, { recursive: true, force: true });
|
|
@@ -914,6 +889,30 @@ function copyPluginDir(src: string, dest: string): SetupResultStatus {
|
|
|
914
889
|
return hadDir ? "Configured" : "Installed";
|
|
915
890
|
}
|
|
916
891
|
|
|
892
|
+
// CA-08/CA-09: remove the exact legacy local plugin identity only AFTER the
|
|
893
|
+
// canonical `workit` copy and registration succeeded. The legacy dir's own
|
|
894
|
+
// user-compiled rules are carried forward first so no user data is lost; the
|
|
895
|
+
// `.workflow-toolkit-root` marker, share path, and unrelated sibling dirs are
|
|
896
|
+
// left untouched.
|
|
897
|
+
const removeLegacyCursorDir = (res: ResolvedApply): void => {
|
|
898
|
+
const legacy = path.join(res.home, ".cursor", "plugins", "local", "workflow-toolkit");
|
|
899
|
+
if (legacy === res.cursorPluginDir || !existsSync(legacy)) return;
|
|
900
|
+
const legacyRules = path.join(legacy, "rules");
|
|
901
|
+
if (existsSync(legacyRules)) {
|
|
902
|
+
try {
|
|
903
|
+
mkdirSync(path.join(res.cursorPluginDir, "rules"), { recursive: true });
|
|
904
|
+
for (const entry of readdirSync(legacyRules, { withFileTypes: true })) {
|
|
905
|
+
if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
|
|
906
|
+
const target = path.join(res.cursorPluginDir, "rules", entry.name);
|
|
907
|
+
if (!existsSync(target)) copyFileSync(path.join(legacyRules, entry.name), target);
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
/* best-effort rule carry-forward */
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
rmSync(legacy, { recursive: true, force: true });
|
|
914
|
+
};
|
|
915
|
+
|
|
917
916
|
// One reviewed mutation per Cursor write target (AR-09): the settings merge and
|
|
918
917
|
// the mcp merge are dispatched independently, exactly like the adapter copy.
|
|
919
918
|
function applyCursorSettings(root: string, res: ResolvedApply): SetupResultEntry {
|
|
@@ -1042,6 +1041,10 @@ export function applySetupPreview(
|
|
|
1042
1041
|
// path (a caller that resolved the preview with different options) fails
|
|
1043
1042
|
// fast — Failed, no write — instead of silently writing an unreviewed path.
|
|
1044
1043
|
const rootFor = new Map<Platform, string | null>();
|
|
1044
|
+
// CA-09: cursor registration and legacy cleanup only proceed after the
|
|
1045
|
+
// canonical copy succeeded — a failed replacement must never remove the
|
|
1046
|
+
// legacy identity or its registration.
|
|
1047
|
+
let cursorCopyOk = false;
|
|
1045
1048
|
for (const mutation of preview.mutations) {
|
|
1046
1049
|
if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
|
|
1047
1050
|
entries.push(applyMutation(mutation));
|
|
@@ -1081,7 +1084,9 @@ export function applySetupPreview(
|
|
|
1081
1084
|
}
|
|
1082
1085
|
if (mutation.type === "install-adapter") {
|
|
1083
1086
|
try {
|
|
1084
|
-
|
|
1087
|
+
const status = copyPluginDir(root, mutation.path);
|
|
1088
|
+
cursorCopyOk = true;
|
|
1089
|
+
entries.push({ platform, file: mutation.path, status });
|
|
1085
1090
|
} catch (error) {
|
|
1086
1091
|
entries.push({
|
|
1087
1092
|
platform,
|
|
@@ -1092,6 +1097,9 @@ export function applySetupPreview(
|
|
|
1092
1097
|
}
|
|
1093
1098
|
} else if (platform === "opencode") {
|
|
1094
1099
|
entries.push(applyOpenCode(root, res));
|
|
1100
|
+
} else if (!cursorCopyOk) {
|
|
1101
|
+
// CA-09: the canonical copy failed — leave the legacy registration intact.
|
|
1102
|
+
continue;
|
|
1095
1103
|
} else {
|
|
1096
1104
|
entries.push(
|
|
1097
1105
|
mutation.path === res.cursorSettings
|
|
@@ -1100,6 +1108,15 @@ export function applySetupPreview(
|
|
|
1100
1108
|
);
|
|
1101
1109
|
}
|
|
1102
1110
|
}
|
|
1111
|
+
// CA-08/CA-09: migrate the legacy local identity only after the canonical
|
|
1112
|
+
// copy AND registration both succeeded.
|
|
1113
|
+
if (
|
|
1114
|
+
preview.platforms.includes("cursor") &&
|
|
1115
|
+
cursorCopyOk &&
|
|
1116
|
+
!entries.some((e) => e.platform === "cursor" && e.status === "Failed")
|
|
1117
|
+
) {
|
|
1118
|
+
removeLegacyCursorDir(res);
|
|
1119
|
+
}
|
|
1103
1120
|
for (const preserved of preview.preserved) {
|
|
1104
1121
|
entries.push({
|
|
1105
1122
|
platform: "core",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// host that has no executable artifact/test evidence.
|
|
7
7
|
export const SUPPORT_MATRIX = {
|
|
8
8
|
bun: "1.3.14",
|
|
9
|
-
node: { minimum: "
|
|
9
|
+
node: { minimum: "22", current: "22" },
|
|
10
10
|
opencode: { minimum: "1.15.0", current: "1.17.7" },
|
|
11
11
|
os: ["ubuntu-latest", "macos-latest", "windows-latest"],
|
|
12
12
|
} as const;
|
package/src/core/sync-runtime.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import {
|
|
3
3
|
accessSync,
|
|
4
4
|
constants,
|
|
5
|
+
copyFileSync,
|
|
5
6
|
existsSync,
|
|
6
7
|
mkdirSync,
|
|
7
8
|
readFileSync,
|
|
@@ -107,7 +108,7 @@ export async function syncRuntime(options: SyncRuntimeOptions = {}): Promise<Syn
|
|
|
107
108
|
path.join(home, "Documents/projects/personal/workflow-toolkit");
|
|
108
109
|
const repoSlug = options.repoSlug ?? env.WORKFLOW_TOOLKIT_REPO ?? "BrainerVirus/workit";
|
|
109
110
|
const share = path.join(home, ".local/share/workflow-toolkit");
|
|
110
|
-
const pluginDir = path.join(home, ".cursor/plugins/local/
|
|
111
|
+
const pluginDir = path.join(home, ".cursor/plugins/local/workit");
|
|
111
112
|
const opencodePlugins = path.join(home, ".config/opencode/plugins");
|
|
112
113
|
const lock = path.join(
|
|
113
114
|
options.lockDir ?? env.XDG_RUNTIME_DIR ?? "/tmp",
|
|
@@ -275,6 +276,23 @@ export async function syncRuntime(options: SyncRuntimeOptions = {}): Promise<Syn
|
|
|
275
276
|
`${share}/packages/workit-core\n`,
|
|
276
277
|
);
|
|
277
278
|
|
|
279
|
+
// CA-08/CA-09: migrate the legacy local plugin identity to `workit` only
|
|
280
|
+
// after the canonical sync succeeded; carry the legacy user rules forward
|
|
281
|
+
// first so no user data is lost.
|
|
282
|
+
const legacyDir = path.join(home, ".cursor", "plugins", "local", "workflow-toolkit");
|
|
283
|
+
if (legacyDir !== pluginDir && existsSync(legacyDir)) {
|
|
284
|
+
const legacyRules = path.join(legacyDir, "rules");
|
|
285
|
+
if (existsSync(legacyRules)) {
|
|
286
|
+
mkdirSync(path.join(pluginDir, "rules"), { recursive: true });
|
|
287
|
+
for (const entry of readdirSync(legacyRules, { withFileTypes: true })) {
|
|
288
|
+
if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
|
|
289
|
+
const target = path.join(pluginDir, "rules", entry.name);
|
|
290
|
+
if (!existsSync(target)) copyFileSync(path.join(legacyRules, entry.name), target);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
rmSync(legacyDir, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
|
|
278
296
|
// Remove broken TLA live-loader if present (OpenCode ignored it; /wk-* vanished).
|
|
279
297
|
rmSync(path.join(opencodePlugins, "workflow-toolkit.ts"), { force: true });
|
|
280
298
|
|