qa-ai-repo 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/_template/README.md +20 -0
- package/_template/agents/.gitkeep +0 -0
- package/_template/mcp/.gitkeep +0 -0
- package/_template/objective.json +4 -0
- package/_template/skills/.gitkeep +0 -0
- package/bin/qa-ai.js +7 -0
- package/package.json +40 -0
- package/playwright-e2e/agents/qa-e2e-author.md +17 -0
- package/playwright-e2e/mcp/playwright.json +4 -0
- package/playwright-e2e/objective.json +4 -0
- package/playwright-e2e/skills/playwright-e2e/SKILL.md +32 -0
- package/src/cli.js +129 -0
- package/src/install.js +214 -0
- package/src/registry.js +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pnakhat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# qa-ai-repo
|
|
2
|
+
|
|
3
|
+
Reusable **QA skills, agents, and MCP servers**, organized by objective and
|
|
4
|
+
installable into any AI coding tool with one command.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx qa-ai-repo list
|
|
8
|
+
npx qa-ai-repo add playwright-e2e
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
Each QA **objective** is a top-level folder containing only the pieces it needs:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
playwright-e2e/
|
|
17
|
+
├── objective.json # title + description (optional)
|
|
18
|
+
├── skills/ # Claude-style SKILL.md skills
|
|
19
|
+
├── agents/ # Claude-style subagent definitions
|
|
20
|
+
└── mcp/ # MCP server definitions (one JSON per server)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The CLI reads an objective and writes each piece into the right place for
|
|
24
|
+
whichever AI tool you target — converting formats where needed:
|
|
25
|
+
|
|
26
|
+
| Source | Claude Code | Cursor | Windsurf | Generic |
|
|
27
|
+
|---------------|---------------------------------|---------------------------------|------------------------------|------------------|
|
|
28
|
+
| `skills/*` | `.claude/skills/<name>/` | `.cursor/rules/<name>.mdc` | `.windsurf/rules/<name>.md` | `AGENTS.md` |
|
|
29
|
+
| `agents/*.md` | `.claude/agents/<name>.md` | `.cursor/rules/<name>.mdc` | `.windsurf/rules/<name>.md` | `AGENTS.md` |
|
|
30
|
+
| `mcp/*.json` | `.mcp.json` (merged) | `.cursor/mcp.json` (merged) | global `mcp_config.json` | printed to add |
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx qa-ai-repo list # list objectives
|
|
36
|
+
npx qa-ai-repo add <objective> # install into detected tools
|
|
37
|
+
npx qa-ai-repo add <objective> --tool cursor # target specific tool(s)
|
|
38
|
+
npx qa-ai-repo add <objective> --tool all # every supported tool
|
|
39
|
+
npx qa-ai-repo add <objective> --dry-run # preview, write nothing
|
|
40
|
+
npx qa-ai-repo detect # show detected tools
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`--tool` accepts a comma list of `claude`, `cursor`, `windsurf`, `agents`, or
|
|
44
|
+
`all`. With no `--tool`, the CLI auto-detects tools in the current project
|
|
45
|
+
(`.claude` / `.cursor` / `.windsurf`) and falls back to `claude,cursor`.
|
|
46
|
+
|
|
47
|
+
MCP servers are **merged** into existing config files, so `add` is safe to run
|
|
48
|
+
repeatedly and across multiple objectives.
|
|
49
|
+
|
|
50
|
+
## Add a new objective
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cp -r _template my-objective # scaffold
|
|
54
|
+
# edit my-objective/objective.json and drop files into skills/ agents/ mcp/
|
|
55
|
+
# remove any of the three folders the objective doesn't use
|
|
56
|
+
npx qa-ai-repo add my-objective # try it locally
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Local development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
node bin/qa-ai.js list # run without publishing
|
|
63
|
+
npm link # then `qa-ai list` works anywhere
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Publishing
|
|
67
|
+
|
|
68
|
+
Bump `version` in `package.json`, then `npm publish`. Objective folders ship
|
|
69
|
+
automatically (see `.npmignore`); no need to enumerate them.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# <Objective Name>
|
|
2
|
+
|
|
3
|
+
> One-line description of the QA objective this folder covers.
|
|
4
|
+
|
|
5
|
+
## What this objective covers
|
|
6
|
+
Describe the QA goal — e.g. "End-to-end regression for the checkout flow",
|
|
7
|
+
"API contract testing for the payments service", "Flaky-test triage".
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
| Folder | What lives here |
|
|
12
|
+
|--------|-----------------|
|
|
13
|
+
| `skills/` | Reusable skills/prompt workflows for this objective |
|
|
14
|
+
| `agents/` | Agent definitions that carry out this objective |
|
|
15
|
+
| `mcp/` | MCP server configs/implementations this objective needs |
|
|
16
|
+
|
|
17
|
+
> Delete any of the folders above that this objective does not use.
|
|
18
|
+
|
|
19
|
+
## How to use
|
|
20
|
+
Notes on wiring these into Claude Code / your QA workflow.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/bin/qa-ai.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qa-ai-repo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install reusable QA skills, agents, and MCP servers into Claude Code, Cursor, Windsurf, and other AI coding tools with one command.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"qa-ai": "bin/qa-ai.js",
|
|
8
|
+
"qa-ai-repo": "bin/qa-ai.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/qa-ai.js",
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"qa",
|
|
19
|
+
"testing",
|
|
20
|
+
"ai",
|
|
21
|
+
"agents",
|
|
22
|
+
"skills",
|
|
23
|
+
"mcp",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"cursor",
|
|
26
|
+
"windsurf",
|
|
27
|
+
"playwright",
|
|
28
|
+
"e2e"
|
|
29
|
+
],
|
|
30
|
+
"author": "pnakhat",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/pnakhat/qa-ai-repo.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/pnakhat/qa-ai-repo/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/pnakhat/qa-ai-repo#readme"
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qa-e2e-author
|
|
3
|
+
description: Use to author or extend Playwright end-to-end tests for a user journey. Give it the flow to cover; it produces Page Object Model specs with stable locators and web-first assertions.
|
|
4
|
+
tools: Read, Grep, Glob, Edit, Write, Bash
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are a senior QA automation engineer specializing in Playwright E2E tests.
|
|
8
|
+
|
|
9
|
+
When asked to cover a user journey:
|
|
10
|
+
1. Inspect the app/routes and existing `tests/` layout before writing anything.
|
|
11
|
+
2. Reuse or create Page Objects under `tests/pages/`; expose intent-level methods.
|
|
12
|
+
3. Write specs under `tests/e2e/` named by journey, each fully isolated.
|
|
13
|
+
4. Use user-facing locators (`getByRole`/`getByLabel`/`getByText`); `getByTestId` only as a fallback.
|
|
14
|
+
5. Assert with web-first, auto-retrying assertions — never `waitForTimeout`.
|
|
15
|
+
6. Run `npx playwright test` for the new spec and iterate until green.
|
|
16
|
+
|
|
17
|
+
Report: the files you added/changed, the journeys covered, and any gaps you could not test.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: playwright-e2e
|
|
3
|
+
description: Author and maintain resilient Playwright end-to-end tests using the Page Object Model, fixtures, and stable, user-facing locators. Use when writing, reviewing, or debugging Playwright E2E specs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Playwright E2E Testing
|
|
7
|
+
|
|
8
|
+
Write end-to-end tests that survive UI churn and stay fast.
|
|
9
|
+
|
|
10
|
+
## Locators — prefer user-facing, avoid brittle selectors
|
|
11
|
+
- Prefer `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText` — they mirror how a user finds things.
|
|
12
|
+
- Use `getByTestId` only when no accessible handle exists; keep `data-testid` stable.
|
|
13
|
+
- Never select by CSS/XPath tied to layout or generated class names.
|
|
14
|
+
|
|
15
|
+
## Page Object Model
|
|
16
|
+
- One class per page/major component under `tests/pages/`.
|
|
17
|
+
- Expose intent-level methods (`login(user)`, `addToCart(sku)`), not raw clicks.
|
|
18
|
+
- Return the next Page Object from navigations so flows read top-to-bottom.
|
|
19
|
+
|
|
20
|
+
## Waiting — assertions, never sleeps
|
|
21
|
+
- Rely on Playwright's web-first, auto-retrying assertions (`await expect(locator).toBeVisible()`).
|
|
22
|
+
- Never use `page.waitForTimeout()` to "let things settle"; wait for a condition.
|
|
23
|
+
|
|
24
|
+
## Structure & isolation
|
|
25
|
+
- Each test is independent: set up its own state, no ordering assumptions.
|
|
26
|
+
- Use fixtures for shared setup (authenticated context, seeded data).
|
|
27
|
+
- Keep specs in `tests/e2e/*.spec.ts`; name by user journey, not by page.
|
|
28
|
+
|
|
29
|
+
## Debugging
|
|
30
|
+
- `npx playwright test --ui` for the time-travel runner.
|
|
31
|
+
- `--trace on` and open `trace.zip` to inspect failures with DOM snapshots.
|
|
32
|
+
- `--headed --debug` to step through with the Inspector.
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { listObjectives, loadObjective, KINDS } from './registry.js';
|
|
5
|
+
import { install, detectTools, TOOLS } from './install.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
|
+
|
|
10
|
+
const c = {
|
|
11
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
12
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
13
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
14
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function parseFlags(args) {
|
|
18
|
+
const flags = {};
|
|
19
|
+
const positional = [];
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const a = args[i];
|
|
22
|
+
if (a === '--dry-run' || a === '-n') flags.dryRun = true;
|
|
23
|
+
else if (a === '--tool' || a === '-t') flags.tool = args[++i];
|
|
24
|
+
else if (a.startsWith('--tool=')) flags.tool = a.slice('--tool='.length);
|
|
25
|
+
else positional.push(a);
|
|
26
|
+
}
|
|
27
|
+
return { flags, positional };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveTools(flag) {
|
|
31
|
+
if (flag) {
|
|
32
|
+
const requested = flag.split(',').map((s) => s.trim()).filter(Boolean);
|
|
33
|
+
if (requested.includes('all')) return { tools: TOOLS, note: '' };
|
|
34
|
+
for (const t of requested) {
|
|
35
|
+
if (!TOOLS.includes(t)) throw new Error(`unknown tool "${t}" (known: ${TOOLS.join(', ')}, all)`);
|
|
36
|
+
}
|
|
37
|
+
return { tools: requested, note: '' };
|
|
38
|
+
}
|
|
39
|
+
const detected = detectTools();
|
|
40
|
+
if (detected.length) return { tools: detected, note: `auto-detected: ${detected.join(', ')}` };
|
|
41
|
+
return { tools: ['claude', 'cursor'], note: 'no tool detected — defaulting to claude, cursor (use --tool to override)' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cmdList() {
|
|
45
|
+
const objectives = listObjectives();
|
|
46
|
+
if (!objectives.length) {
|
|
47
|
+
console.log('No objectives yet. Add a folder with skills/ agents/ mcp/ under it.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(`\n${c.bold('QA objectives')} ${c.dim('(' + pkg.name + ' v' + pkg.version + ')')}\n`);
|
|
51
|
+
for (const o of objectives) {
|
|
52
|
+
console.log(` ${c.cyan(o.name)} ${c.dim('— ' + o.title)}`);
|
|
53
|
+
if (o.description) console.log(` ${c.dim(o.description)}`);
|
|
54
|
+
const parts = KINDS
|
|
55
|
+
.filter((k) => o.contents[k].length)
|
|
56
|
+
.map((k) => `${o.contents[k].length} ${k}`);
|
|
57
|
+
console.log(` ${c.dim(parts.join(' · ') || 'empty')}\n`);
|
|
58
|
+
}
|
|
59
|
+
console.log(c.dim(`Install with: npx ${pkg.name} add <objective>\n`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cmdDetect() {
|
|
63
|
+
const detected = detectTools();
|
|
64
|
+
console.log(detected.length
|
|
65
|
+
? `Detected tools in this project: ${c.green(detected.join(', '))}`
|
|
66
|
+
: 'No AI tools detected in this project (no .claude/.cursor/.windsurf).');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cmdAdd(positional, flags) {
|
|
70
|
+
const name = positional[0];
|
|
71
|
+
if (!name) throw new Error(`missing objective name. Try: npx ${pkg.name} list`);
|
|
72
|
+
|
|
73
|
+
const objective = loadObjective(name);
|
|
74
|
+
if (!objective) {
|
|
75
|
+
const available = listObjectives().map((o) => o.name).join(', ');
|
|
76
|
+
throw new Error(`objective "${name}" not found. Available: ${available || '(none)'}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { tools, note } = resolveTools(flags.tool);
|
|
80
|
+
console.log(`\nInstalling ${c.cyan(objective.name)} into: ${c.bold(tools.join(', '))}`);
|
|
81
|
+
if (note) console.log(c.dim(note));
|
|
82
|
+
if (flags.dryRun) console.log(c.dim('(dry run — no files written)'));
|
|
83
|
+
|
|
84
|
+
install(objective, tools, { dryRun: flags.dryRun });
|
|
85
|
+
|
|
86
|
+
console.log(`\n${flags.dryRun ? c.dim('Dry run complete.') : c.green('Done.')}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function help() {
|
|
90
|
+
console.log(`
|
|
91
|
+
${c.bold(pkg.name)} ${c.dim('v' + pkg.version)}
|
|
92
|
+
${pkg.description}
|
|
93
|
+
|
|
94
|
+
${c.bold('Usage')}
|
|
95
|
+
npx ${pkg.name} <command> [options]
|
|
96
|
+
|
|
97
|
+
${c.bold('Commands')}
|
|
98
|
+
list List available QA objectives
|
|
99
|
+
add <objective> Install an objective's skills/agents/mcp
|
|
100
|
+
detect Show which AI tools are detected here
|
|
101
|
+
help Show this help
|
|
102
|
+
|
|
103
|
+
${c.bold('Options')}
|
|
104
|
+
-t, --tool <list> Comma-separated: ${TOOLS.join(', ')}, all
|
|
105
|
+
(default: auto-detected, else claude,cursor)
|
|
106
|
+
-n, --dry-run Show what would be written without writing
|
|
107
|
+
|
|
108
|
+
${c.bold('Examples')}
|
|
109
|
+
npx ${pkg.name} list
|
|
110
|
+
npx ${pkg.name} add playwright-e2e
|
|
111
|
+
npx ${pkg.name} add playwright-e2e --tool cursor,claude
|
|
112
|
+
npx ${pkg.name} add playwright-e2e --tool all --dry-run
|
|
113
|
+
`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function main(argv) {
|
|
117
|
+
const { flags, positional } = parseFlags(argv);
|
|
118
|
+
const cmd = positional.shift();
|
|
119
|
+
|
|
120
|
+
if (flags.version || cmd === '--version') return void console.log(pkg.version);
|
|
121
|
+
switch (cmd) {
|
|
122
|
+
case 'list': case 'ls': return cmdList();
|
|
123
|
+
case 'add': case 'install': return cmdAdd(positional, flags);
|
|
124
|
+
case 'detect': return cmdDetect();
|
|
125
|
+
case undefined: case 'help': case '--help': case '-h': return help();
|
|
126
|
+
default:
|
|
127
|
+
throw new Error(`unknown command "${cmd}". Run: npx ${pkg.name} help`);
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Installs an objective's skills/agents/mcp into a target AI tool by writing
|
|
2
|
+
// the files each tool expects. All writes are relative to process.cwd() (the
|
|
3
|
+
// user's project), except Windsurf's MCP config which is global.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
existsSync, readFileSync, writeFileSync, mkdirSync, cpSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { join, dirname, basename } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { KINDS } from './registry.js';
|
|
11
|
+
|
|
12
|
+
const c = {
|
|
13
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
14
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
15
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
16
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ---- small helpers -------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function parseFrontmatter(md) {
|
|
22
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
23
|
+
if (!m) return { data: {}, body: md };
|
|
24
|
+
const data = {};
|
|
25
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
26
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
27
|
+
if (kv) data[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
28
|
+
}
|
|
29
|
+
return { data, body: m[2] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toMdc(data, body) {
|
|
33
|
+
const desc = (data.description || '').replace(/\n/g, ' ');
|
|
34
|
+
return `---\ndescription: ${desc}\nglobs:\nalwaysApply: false\n---\n\n${body.trim()}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- write primitives ----------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function ensureWrite(ctx, absPath, content) {
|
|
40
|
+
const rel = relForLog(absPath);
|
|
41
|
+
if (ctx.dryRun) {
|
|
42
|
+
ctx.log(` ${c.yellow('would write')} ${rel}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
46
|
+
writeFileSync(absPath, content);
|
|
47
|
+
ctx.log(` ${c.green('wrote')} ${rel}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function copyDir(ctx, src, dest) {
|
|
51
|
+
const rel = relForLog(dest);
|
|
52
|
+
if (ctx.dryRun) {
|
|
53
|
+
ctx.log(` ${c.yellow('would copy')} ${rel}${'/'}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
57
|
+
cpSync(src, dest, { recursive: true });
|
|
58
|
+
ctx.log(` ${c.green('copied')} ${rel}/`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mergeMcp(ctx, targetPath, serverName, serverDef) {
|
|
62
|
+
let config = { mcpServers: {} };
|
|
63
|
+
if (existsSync(targetPath)) {
|
|
64
|
+
try {
|
|
65
|
+
config = JSON.parse(readFileSync(targetPath, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`${targetPath} exists but is not valid JSON; fix or remove it first`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
config.mcpServers = config.mcpServers || {};
|
|
71
|
+
const existed = Boolean(config.mcpServers[serverName]);
|
|
72
|
+
config.mcpServers[serverName] = serverDef;
|
|
73
|
+
|
|
74
|
+
const rel = relForLog(targetPath);
|
|
75
|
+
const verb = existed ? 'updated mcp' : 'added mcp';
|
|
76
|
+
if (ctx.dryRun) {
|
|
77
|
+
ctx.log(` ${c.yellow('would ' + verb)} "${serverName}" -> ${rel}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
81
|
+
writeFileSync(targetPath, JSON.stringify(config, null, 2) + '\n');
|
|
82
|
+
ctx.log(` ${c.green(verb)} "${serverName}" -> ${rel}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function relForLog(abs) {
|
|
86
|
+
const cwd = process.cwd();
|
|
87
|
+
return abs.startsWith(cwd) ? abs.slice(cwd.length + 1) : abs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- tool adapters -------------------------------------------------------
|
|
91
|
+
// Each adapter maps the three kinds into the files a given tool consumes.
|
|
92
|
+
|
|
93
|
+
function readSkill(objective, skillName) {
|
|
94
|
+
const skillMd = join(objective.dir, 'skills', skillName, 'SKILL.md');
|
|
95
|
+
const raw = existsSync(skillMd) ? readFileSync(skillMd, 'utf8') : '';
|
|
96
|
+
const { data, body } = parseFrontmatter(raw);
|
|
97
|
+
return { skillMd, raw, data, body };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const adapters = {
|
|
101
|
+
claude: {
|
|
102
|
+
label: 'Claude Code',
|
|
103
|
+
skill(ctx, objective, skillName) {
|
|
104
|
+
copyDir(ctx, join(objective.dir, 'skills', skillName), join(ctx.cwd, '.claude', 'skills', skillName));
|
|
105
|
+
},
|
|
106
|
+
agent(ctx, objective, agentFile) {
|
|
107
|
+
const src = join(objective.dir, 'agents', agentFile);
|
|
108
|
+
ensureWrite(ctx, join(ctx.cwd, '.claude', 'agents', agentFile), readFileSync(src, 'utf8'));
|
|
109
|
+
},
|
|
110
|
+
mcp(ctx, objective, mcpFile) {
|
|
111
|
+
const name = basename(mcpFile, '.json');
|
|
112
|
+
const def = JSON.parse(readFileSync(join(objective.dir, 'mcp', mcpFile), 'utf8'));
|
|
113
|
+
mergeMcp(ctx, join(ctx.cwd, '.mcp.json'), name, def);
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
cursor: {
|
|
118
|
+
label: 'Cursor',
|
|
119
|
+
skill(ctx, objective, skillName) {
|
|
120
|
+
const { data, body } = readSkill(objective, skillName);
|
|
121
|
+
ensureWrite(ctx, join(ctx.cwd, '.cursor', 'rules', `${skillName}.mdc`), toMdc(data, body));
|
|
122
|
+
},
|
|
123
|
+
agent(ctx, objective, agentFile) {
|
|
124
|
+
const name = basename(agentFile, '.md');
|
|
125
|
+
const { data, body } = parseFrontmatter(readFileSync(join(objective.dir, 'agents', agentFile), 'utf8'));
|
|
126
|
+
ensureWrite(ctx, join(ctx.cwd, '.cursor', 'rules', `${name}.mdc`), toMdc(data, body));
|
|
127
|
+
},
|
|
128
|
+
mcp(ctx, objective, mcpFile) {
|
|
129
|
+
const name = basename(mcpFile, '.json');
|
|
130
|
+
const def = JSON.parse(readFileSync(join(objective.dir, 'mcp', mcpFile), 'utf8'));
|
|
131
|
+
mergeMcp(ctx, join(ctx.cwd, '.cursor', 'mcp.json'), name, def);
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
windsurf: {
|
|
136
|
+
label: 'Windsurf',
|
|
137
|
+
skill(ctx, objective, skillName) {
|
|
138
|
+
const { data, body } = readSkill(objective, skillName);
|
|
139
|
+
const md = `# ${data.name || skillName}\n\n${data.description || ''}\n\n${body.trim()}\n`;
|
|
140
|
+
ensureWrite(ctx, join(ctx.cwd, '.windsurf', 'rules', `${skillName}.md`), md);
|
|
141
|
+
},
|
|
142
|
+
agent(ctx, objective, agentFile) {
|
|
143
|
+
const name = basename(agentFile, '.md');
|
|
144
|
+
const { data, body } = parseFrontmatter(readFileSync(join(objective.dir, 'agents', agentFile), 'utf8'));
|
|
145
|
+
const md = `# ${data.name || name}\n\n${data.description || ''}\n\n${body.trim()}\n`;
|
|
146
|
+
ensureWrite(ctx, join(ctx.cwd, '.windsurf', 'rules', `${name}.md`), md);
|
|
147
|
+
},
|
|
148
|
+
mcp(ctx, objective, mcpFile) {
|
|
149
|
+
const name = basename(mcpFile, '.json');
|
|
150
|
+
const def = JSON.parse(readFileSync(join(objective.dir, 'mcp', mcpFile), 'utf8'));
|
|
151
|
+
// Windsurf reads a single global MCP config file.
|
|
152
|
+
mergeMcp(ctx, join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), name, def);
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
agents: {
|
|
157
|
+
label: 'AGENTS.md (generic)',
|
|
158
|
+
skill(ctx, objective, skillName) {
|
|
159
|
+
const { data, body } = readSkill(objective, skillName);
|
|
160
|
+
appendAgentsMd(ctx, data.name || skillName, data.description, body);
|
|
161
|
+
},
|
|
162
|
+
agent(ctx, objective, agentFile) {
|
|
163
|
+
const name = basename(agentFile, '.md');
|
|
164
|
+
const { data, body } = parseFrontmatter(readFileSync(join(objective.dir, 'agents', agentFile), 'utf8'));
|
|
165
|
+
appendAgentsMd(ctx, data.name || name, data.description, body);
|
|
166
|
+
},
|
|
167
|
+
mcp(ctx, objective, mcpFile) {
|
|
168
|
+
const name = basename(mcpFile, '.json');
|
|
169
|
+
const def = readFileSync(join(objective.dir, 'mcp', mcpFile), 'utf8').trim();
|
|
170
|
+
ctx.log(` ${c.yellow('mcp (manual)')} add "${name}" to your tool's MCP config:`);
|
|
171
|
+
ctx.log(c.dim(def.split('\n').map((l) => ' ' + l).join('\n')));
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function appendAgentsMd(ctx, name, description, body) {
|
|
177
|
+
const target = join(ctx.cwd, 'AGENTS.md');
|
|
178
|
+
const heading = `## ${name}`;
|
|
179
|
+
const section = `${heading}\n\n${description ? description + '\n\n' : ''}${body.trim()}\n`;
|
|
180
|
+
let existing = existsSync(target) ? readFileSync(target, 'utf8') : '# AGENTS.md\n\n';
|
|
181
|
+
if (existing.includes(heading)) {
|
|
182
|
+
ctx.log(` ${c.dim('skip')} AGENTS.md already has "${name}"`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
ensureWrite(ctx, target, existing.replace(/\s*$/, '\n\n') + section);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const TOOLS = Object.keys(adapters);
|
|
189
|
+
|
|
190
|
+
// ---- tool detection ------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export function detectTools(cwd = process.cwd()) {
|
|
193
|
+
const found = [];
|
|
194
|
+
if (existsSync(join(cwd, '.claude')) || existsSync(join(cwd, '.mcp.json')) || existsSync(join(cwd, 'CLAUDE.md'))) found.push('claude');
|
|
195
|
+
if (existsSync(join(cwd, '.cursor')) || existsSync(join(cwd, '.cursorrules'))) found.push('cursor');
|
|
196
|
+
if (existsSync(join(cwd, '.windsurf'))) found.push('windsurf');
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- orchestration -------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export function install(objective, tools, { dryRun = false, cwd = process.cwd(), log = console.log } = {}) {
|
|
203
|
+
const ctx = { dryRun, cwd, log };
|
|
204
|
+
for (const tool of tools) {
|
|
205
|
+
const adapter = adapters[tool];
|
|
206
|
+
if (!adapter) throw new Error(`unknown tool "${tool}" (known: ${TOOLS.join(', ')})`);
|
|
207
|
+
log(`\n${c.cyan('▸ ' + adapter.label)}`);
|
|
208
|
+
for (const kind of KINDS) {
|
|
209
|
+
for (const item of objective.contents[kind]) {
|
|
210
|
+
adapter[kind === 'skills' ? 'skill' : kind === 'agents' ? 'agent' : 'mcp'](ctx, objective, item);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Discovers "objectives" — top-level folders in this repo that contain any of
|
|
2
|
+
// skills/ agents/ mcp/. The package ships its objective folders at the repo
|
|
3
|
+
// root (siblings of bin/ and src/), so we scan REPO_ROOT and skip reserved and
|
|
4
|
+
// underscore-prefixed names (like _template).
|
|
5
|
+
|
|
6
|
+
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
export const REPO_ROOT = join(__dirname, '..');
|
|
12
|
+
|
|
13
|
+
const RESERVED = new Set([
|
|
14
|
+
'bin', 'src', 'node_modules', 'scripts', 'test', 'tests', 'coverage',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const KINDS = ['skills', 'agents', 'mcp'];
|
|
18
|
+
|
|
19
|
+
function isReserved(name) {
|
|
20
|
+
return RESERVED.has(name) || name.startsWith('.') || name.startsWith('_');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadObjective(name) {
|
|
24
|
+
const dir = join(REPO_ROOT, name);
|
|
25
|
+
if (!existsSync(dir)) return null;
|
|
26
|
+
|
|
27
|
+
const contents = {};
|
|
28
|
+
let hasAny = false;
|
|
29
|
+
for (const kind of KINDS) {
|
|
30
|
+
const kdir = join(dir, kind);
|
|
31
|
+
if (existsSync(kdir)) {
|
|
32
|
+
contents[kind] = readdirSync(kdir, { withFileTypes: true })
|
|
33
|
+
.filter((e) => !e.name.startsWith('.'))
|
|
34
|
+
.map((e) => e.name);
|
|
35
|
+
if (contents[kind].length) hasAny = true;
|
|
36
|
+
} else {
|
|
37
|
+
contents[kind] = [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const metaPath = join(dir, 'objective.json');
|
|
42
|
+
let meta = {};
|
|
43
|
+
if (existsSync(metaPath)) {
|
|
44
|
+
try {
|
|
45
|
+
meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
/* ignore malformed metadata */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!hasAny && !existsSync(metaPath)) return null;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
dir,
|
|
56
|
+
title: meta.title || name,
|
|
57
|
+
description: meta.description || '',
|
|
58
|
+
contents,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listObjectives() {
|
|
63
|
+
return readdirSync(REPO_ROOT, { withFileTypes: true })
|
|
64
|
+
.filter((d) => d.isDirectory() && !isReserved(d.name))
|
|
65
|
+
.map((d) => loadObjective(d.name))
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
68
|
+
}
|