@test-lab-ai/cli 0.2.2 → 0.2.6
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/AGENTS.md +4 -4
- package/README.md +26 -12
- package/bin/testlab.mjs +87 -16
- package/lib/examples.mjs +4 -4
- package/lib/skills.mjs +90 -22
- package/lib/update-check.mjs +85 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -23,12 +23,12 @@ npx @test-lab-ai/cli --help
|
|
|
23
23
|
- Put the explicit, fully-qualified URL in the `prompt` (imported plans have
|
|
24
24
|
no project, so there is no base URL to inherit).
|
|
25
25
|
- Replace any secret (passwords, tokens, test-account logins) with a
|
|
26
|
-
`{{credentials
|
|
26
|
+
`{{credentials.<key>}}` placeholder, and collect the real values into the
|
|
27
27
|
top-level `credentials` array.
|
|
28
28
|
- Write clear pass/fail expectations into the prompt ("Confirm the dashboard
|
|
29
29
|
loads", "Expect an order-confirmation page with an order number").
|
|
30
30
|
- For generated/randomized data (a unique email per run, a random name),
|
|
31
|
-
define a **data fixture** and reference it as `{{data
|
|
31
|
+
define a **data fixture** and reference it as `{{data.<fixture>.<field>}}`
|
|
32
32
|
(see below).
|
|
33
33
|
- If the user's repo has a test-lab plan skill/format, prefer it.
|
|
34
34
|
3. **Write** the plans to a JSON file (or a directory of `*.json`).
|
|
@@ -43,7 +43,7 @@ Authentication: the user runs `testlab login` once (browser), or you set
|
|
|
43
43
|
| Field | Type | Required | Notes |
|
|
44
44
|
|-------|------|----------|-------|
|
|
45
45
|
| `name` | string | yes | Max 200 chars |
|
|
46
|
-
| `prompt` | string | yes | The test in natural language, with explicit URL(s) and `{{credentials
|
|
46
|
+
| `prompt` | string | yes | The test in natural language, with explicit URL(s) and `{{credentials.<key>}}`. Max 32 KB |
|
|
47
47
|
| `ref` | string | no | A handle unique within this import, used only to wire pre-steps (see below) |
|
|
48
48
|
| `testType` | `"quickTest"` \| `"deepTest"` | no | Quick is a fast smoke; deep is more thorough |
|
|
49
49
|
| `agentType` | string | no | `functional` (default), `accessibility`, `uiux`, `exploratory`, `performance`, `security` |
|
|
@@ -96,7 +96,7 @@ that matches no plan are rejected before anything is written.
|
|
|
96
96
|
## Credentials
|
|
97
97
|
|
|
98
98
|
Put real secret values in the top-level `credentials` array; reference them in
|
|
99
|
-
prompts (and cookie/header values) as `{{credentials
|
|
99
|
+
prompts (and cookie/header values) as `{{credentials.<key>}}`. Keys start with a
|
|
100
100
|
letter and use letters/numbers/underscores only. The CLI upserts credentials
|
|
101
101
|
before creating plans, and values are stored encrypted (never echoed back).
|
|
102
102
|
|
package/README.md
CHANGED
|
@@ -37,9 +37,11 @@ testlab whoami Show the authenticated account
|
|
|
37
37
|
testlab import <path> [--dry-run] Import a file or directory of *.json
|
|
38
38
|
testlab plans list List your test plans
|
|
39
39
|
testlab plans create -f plan.json Create one plan from JSON
|
|
40
|
-
testlab credentials set
|
|
40
|
+
testlab credentials set <key> --value <value> Set a credential ({{credentials.<key>}})
|
|
41
|
+
testlab credentials list List credential keys (values never shown)
|
|
42
|
+
testlab labels list List your labels
|
|
41
43
|
testlab data list List your data fixtures
|
|
42
|
-
testlab data create -f fixture.json Create a data fixture ({{data
|
|
44
|
+
testlab data create -f fixture.json Create a data fixture ({{data.<fixture>.<field>}})
|
|
43
45
|
testlab examples Full JSON reference for every resource
|
|
44
46
|
```
|
|
45
47
|
|
|
@@ -96,29 +98,37 @@ in the file doesn't matter. Re-running is safe for credentials/labels/fixtures
|
|
|
96
98
|
|
|
97
99
|
## Reference syntax (inside a plan prompt)
|
|
98
100
|
|
|
99
|
-
- `{{credentials
|
|
100
|
-
- `{{data
|
|
101
|
+
- `{{credentials.<key>}}` — a stored secret (never shown to the AI model).
|
|
102
|
+
- `{{data.<fixture>.<field>}}` — a value from a data fixture (generated test data).
|
|
101
103
|
- `{{run.shortId}}` — a unique per-run id (for unique emails, names, etc.).
|
|
102
104
|
|
|
103
|
-
## Install the test-lab-plan skill (Claude Code)
|
|
105
|
+
## Install the test-lab-plan skill (Claude Code, Codex, Cursor)
|
|
104
106
|
|
|
105
|
-
The companion skill that *writes* test-lab plans
|
|
106
|
-
plan
|
|
107
|
+
The companion skill that *writes* test-lab plans, so an AI agent can design a
|
|
108
|
+
plan and this CLI imports it:
|
|
107
109
|
|
|
108
110
|
```bash
|
|
109
|
-
testlab skills install
|
|
110
|
-
testlab skills install --
|
|
111
|
+
testlab skills install # auto-detects the agent(s) you use
|
|
112
|
+
testlab skills install --agent codex # Codex -> .agents/skills
|
|
113
|
+
testlab skills install --agent cursor # Cursor -> .cursor/rules/test-lab-plan.mdc
|
|
114
|
+
testlab skills install --agent all # all three
|
|
111
115
|
```
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
With no `--agent`, it detects which agents you use (the agent running the command,
|
|
118
|
+
plus the `.claude` / `.agents` / `.cursor` folders in your project) and installs for
|
|
119
|
+
each, defaulting to Claude Code if none are found.
|
|
120
|
+
|
|
121
|
+
Add `--global` for Claude Code or Codex (installs under your home directory);
|
|
122
|
+
Cursor user rules are set in Cursor's Settings. Restart your agent to load it.
|
|
123
|
+
It installs from the public [`Test-Lab-ai/skills`](https://github.com/Test-Lab-ai/skills)
|
|
124
|
+
mirror, so you always get the latest version.
|
|
115
125
|
|
|
116
126
|
## For AI agents
|
|
117
127
|
|
|
118
128
|
Run **`testlab examples`** for the complete JSON reference, or read `AGENTS.md`
|
|
119
129
|
(shipped inside this package). The workflow: read the user's existing tests →
|
|
120
130
|
convert each into the plan/fixture JSON above (explicit URL in the prompt,
|
|
121
|
-
secrets as `{{credentials
|
|
131
|
+
secrets as `{{credentials.<key>}}`, generated data as `{{data.<fixture>.<field>}}`) →
|
|
122
132
|
`testlab import`.
|
|
123
133
|
|
|
124
134
|
## Under the hood
|
|
@@ -126,3 +136,7 @@ secrets as `{{credentials.X}}`, generated data as `{{data.X.Y}}`) →
|
|
|
126
136
|
Every command is a thin wrapper over the public
|
|
127
137
|
[Import API](https://test-lab.ai/docs/api/test-plans); an agent can call that
|
|
128
138
|
directly instead of shelling out to the CLI.
|
|
139
|
+
|
|
140
|
+
The CLI checks npm for a newer version about once a day and prints a one-line
|
|
141
|
+
notice when you're behind. Set `NO_UPDATE_NOTIFIER=1` to silence it (it's also
|
|
142
|
+
auto-suppressed in CI and non-interactive output).
|
package/bin/testlab.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* testlab plans list List the account's test plans
|
|
10
10
|
* testlab plans create -f plan.json Create one plan from a JSON file
|
|
11
11
|
* testlab plans create --name N --prompt P
|
|
12
|
-
* testlab credentials set
|
|
12
|
+
* testlab credentials set <key> --value <value> Upsert an account credential ({{credentials.<key>}})
|
|
13
13
|
* testlab import <path> [--dry-run] Import a plan file or a directory of *.json
|
|
14
14
|
*
|
|
15
15
|
* Auth: --key → $TESTLAB_API_KEY → ~/.test-lab/config.json
|
|
@@ -23,7 +23,8 @@ import { apiFetch } from "../lib/api.mjs"
|
|
|
23
23
|
import { loadImportFile, runImport } from "../lib/import.mjs"
|
|
24
24
|
import { browserLogin } from "../lib/login.mjs"
|
|
25
25
|
import { EXAMPLES_TEXT } from "../lib/examples.mjs"
|
|
26
|
-
import { TESTLAB_SKILLS,
|
|
26
|
+
import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill } from "../lib/skills.mjs"
|
|
27
|
+
import { checkForUpdate, currentVersion } from "../lib/update-check.mjs"
|
|
27
28
|
|
|
28
29
|
const log = (...a) => console.log(...a)
|
|
29
30
|
function errExit(msg) {
|
|
@@ -40,18 +41,22 @@ Usage:
|
|
|
40
41
|
(credentials, labels, fixtures, plans)
|
|
41
42
|
testlab plans list List your test plans
|
|
42
43
|
testlab plans create -f plan.json Create one plan from JSON
|
|
43
|
-
testlab credentials set
|
|
44
|
+
testlab credentials set <key> --value <value> Set a credential ({{credentials.<key>}})
|
|
45
|
+
testlab credentials list List credential keys (values never shown)
|
|
46
|
+
testlab labels list List your labels
|
|
44
47
|
testlab data list List your data fixtures
|
|
45
|
-
testlab data create -f fixture.json Create a data fixture ({{data
|
|
48
|
+
testlab data create -f fixture.json Create a data fixture ({{data.<fixture>.<field>}})
|
|
46
49
|
testlab examples Print the full JSON reference for every
|
|
47
50
|
resource (designed for AI agents)
|
|
48
|
-
testlab skills install [--
|
|
51
|
+
testlab skills install [--agent ...] Install the test-lab-plan skill (auto-detects
|
|
52
|
+
Claude/Codex/Cursor; --agent claude|codex|cursor|all)
|
|
49
53
|
|
|
50
54
|
Options:
|
|
51
55
|
--key <tl_…> API key (else $TESTLAB_API_KEY or ~/.test-lab/config.json)
|
|
52
56
|
--stdin Read the value (key/credential) from stdin
|
|
53
57
|
--force (login) re-authenticate even if a stored key still works
|
|
54
58
|
--dry-run (import) validate + print plan order without writing
|
|
59
|
+
--version, -v Print the installed CLI version
|
|
55
60
|
|
|
56
61
|
Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
|
|
57
62
|
|
|
@@ -69,7 +74,8 @@ function parse() {
|
|
|
69
74
|
stdin: { type: "boolean" },
|
|
70
75
|
force: { type: "boolean" },
|
|
71
76
|
global: { type: "boolean" },
|
|
72
|
-
|
|
77
|
+
agent: { type: "string" },
|
|
78
|
+
version: { type: "boolean", short: "v" },
|
|
73
79
|
help: { type: "boolean", short: "h" },
|
|
74
80
|
},
|
|
75
81
|
})
|
|
@@ -195,6 +201,36 @@ async function cmdCredentialsSet(flags, args) {
|
|
|
195
201
|
log(`✓ Set credential ${key}`)
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
async function cmdCredentialsList(flags) {
|
|
205
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
206
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/credentials")
|
|
207
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
208
|
+
const creds = r.json?.credentials || []
|
|
209
|
+
if (creds.length === 0) {
|
|
210
|
+
log("No credentials yet.")
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
// Keys only; the API never returns credential values.
|
|
214
|
+
for (const c of creds) log(` ${c.key}`)
|
|
215
|
+
log(`\n${creds.length} credential(s)`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function cmdLabelsList(flags) {
|
|
219
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
220
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/labels")
|
|
221
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
222
|
+
const labels = r.json?.labels || []
|
|
223
|
+
if (labels.length === 0) {
|
|
224
|
+
log("No labels yet.")
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
for (const l of labels) {
|
|
228
|
+
const n = l.test_plan_count
|
|
229
|
+
log(` ${l.name}${n != null ? ` (${n} plan${n === 1 ? "" : "s"})` : ""}`)
|
|
230
|
+
}
|
|
231
|
+
log(`\n${labels.length} label(s)`)
|
|
232
|
+
}
|
|
233
|
+
|
|
198
234
|
async function cmdImport(flags, args) {
|
|
199
235
|
const target = args[1]
|
|
200
236
|
if (!target) errExit("usage: testlab import <path> [--dry-run]")
|
|
@@ -254,22 +290,46 @@ function cmdExamples() {
|
|
|
254
290
|
|
|
255
291
|
async function cmdSkillsInstall(flags, args) {
|
|
256
292
|
const requested = args[2] ? [args[2]] : TESTLAB_SKILLS
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
293
|
+
|
|
294
|
+
// --agent wins (a single agent, a comma list, or "all"); otherwise auto-detect
|
|
295
|
+
// from the running agent + the project's agent dirs, falling back to Claude.
|
|
296
|
+
let targets
|
|
297
|
+
if (flags.agent) {
|
|
298
|
+
const v = String(flags.agent).toLowerCase()
|
|
299
|
+
targets = v === "all" ? AGENTS : v.split(",").map((s) => s.trim()).filter(Boolean)
|
|
300
|
+
} else {
|
|
301
|
+
targets = detectAgents()
|
|
302
|
+
if (targets.length === 0) {
|
|
303
|
+
targets = ["claude"]
|
|
304
|
+
log("No agent detected; defaulting to Claude Code. Use --agent claude|codex|cursor|all to choose.")
|
|
305
|
+
} else {
|
|
306
|
+
log(`Detected: ${targets.join(", ")} (override with --agent, add --global for your home dir)`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const bad = targets.filter((a) => !AGENTS.includes(a))
|
|
310
|
+
if (bad.length) errExit(`unknown agent(s): ${bad.join(", ")}. Use ${AGENTS.join(", ")}, or all.`)
|
|
311
|
+
|
|
312
|
+
let installed = 0
|
|
313
|
+
for (const agent of targets) {
|
|
314
|
+
for (const name of requested) {
|
|
315
|
+
try {
|
|
316
|
+
const res = await installSkill(name, agent, { global: flags.global })
|
|
317
|
+
installed++
|
|
318
|
+
log(`✓ ${res.name} → ${agent}: ${res.dest}`)
|
|
319
|
+
} catch (e) {
|
|
320
|
+
// When installing for several agents, one failing (e.g. cursor + --global) must not abort the rest.
|
|
321
|
+
if (targets.length > 1) log(` skipped ${agent}: ${e.message}`)
|
|
322
|
+
else errExit(`failed to install ${name} for ${agent}: ${e.message}`)
|
|
323
|
+
}
|
|
264
324
|
}
|
|
265
325
|
}
|
|
266
|
-
log(`\nRestart
|
|
326
|
+
if (installed > 0) log(`\nRestart your agent (start a new session) to load the skill.`)
|
|
267
327
|
}
|
|
268
328
|
|
|
269
329
|
function cmdSkillsList() {
|
|
270
330
|
log(`Available test-lab skills:`)
|
|
271
331
|
for (const s of TESTLAB_SKILLS) log(` ${s}`)
|
|
272
|
-
log(`\nInstall with: testlab skills install [name] [--
|
|
332
|
+
log(`\nInstall with: testlab skills install [name] [--agent ${AGENTS.join("|")}|all]`)
|
|
273
333
|
}
|
|
274
334
|
|
|
275
335
|
async function main() {
|
|
@@ -282,6 +342,13 @@ async function main() {
|
|
|
282
342
|
const flags = parsed.values
|
|
283
343
|
const args = parsed.positionals
|
|
284
344
|
|
|
345
|
+
checkForUpdate() // best-effort "update available" notice (cached, non-blocking)
|
|
346
|
+
|
|
347
|
+
if (flags.version || args[0] === "version") {
|
|
348
|
+
log(currentVersion() || "unknown")
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
285
352
|
if (flags.help || args.length === 0 || args[0] === "help") {
|
|
286
353
|
log(HELP)
|
|
287
354
|
return
|
|
@@ -298,7 +365,11 @@ async function main() {
|
|
|
298
365
|
return errExit("usage: testlab plans <list|create>")
|
|
299
366
|
case "credentials":
|
|
300
367
|
if (args[1] === "set") return cmdCredentialsSet(flags, args)
|
|
301
|
-
|
|
368
|
+
if (args[1] === "list") return cmdCredentialsList(flags)
|
|
369
|
+
return errExit("usage: testlab credentials <set|list>")
|
|
370
|
+
case "labels":
|
|
371
|
+
if (args[1] === "list") return cmdLabelsList(flags)
|
|
372
|
+
return errExit("usage: testlab labels list")
|
|
302
373
|
case "data":
|
|
303
374
|
if (args[1] === "list") return cmdDataList(flags)
|
|
304
375
|
if (args[1] === "create") return cmdDataCreate(flags)
|
package/lib/examples.mjs
CHANGED
|
@@ -10,12 +10,12 @@ All resources are scoped to the API key's account. Auth: a tl_… key via
|
|
|
10
10
|
═══════════════════════════════════════════════════════════════════════════
|
|
11
11
|
REFERENCE SYNTAX (use these inside a plan prompt, and in cookie/header values)
|
|
12
12
|
═══════════════════════════════════════════════════════════════════════════
|
|
13
|
-
{{credentials
|
|
14
|
-
{{data
|
|
13
|
+
{{credentials.<key>}} a stored secret (never shown to the AI/model)
|
|
14
|
+
{{data.<fixture>.<field>}} a value from a data fixture (generated test data)
|
|
15
15
|
{{run.shortId}} a unique per-run id (for unique emails, names, …)
|
|
16
16
|
|
|
17
17
|
═══════════════════════════════════════════════════════════════════════════
|
|
18
|
-
1) CREDENTIAL — a secret behind {{credentials
|
|
18
|
+
1) CREDENTIAL — a secret behind {{credentials.<key>}}
|
|
19
19
|
═══════════════════════════════════════════════════════════════════════════
|
|
20
20
|
Command: testlab credentials set email --value qa@example.com
|
|
21
21
|
JSON (inside an import bundle, under "credentials"):
|
|
@@ -31,7 +31,7 @@ Plans can also list labels by name and they're created on the fly:
|
|
|
31
31
|
"labels": ["smoke", "auth"]
|
|
32
32
|
|
|
33
33
|
═══════════════════════════════════════════════════════════════════════════
|
|
34
|
-
3) DATA FIXTURE — reusable generated test data behind {{data
|
|
34
|
+
3) DATA FIXTURE — reusable generated test data behind {{data.<fixture>.<field>}}
|
|
35
35
|
═══════════════════════════════════════════════════════════════════════════
|
|
36
36
|
Command: testlab data create -f fixture.json
|
|
37
37
|
JSON (under "fixtures"):
|
package/lib/skills.mjs
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install test-lab skills (e.g. test-lab-plan) into a local agent
|
|
3
|
-
*
|
|
2
|
+
* Install test-lab skills (e.g. test-lab-plan) into a local AI coding agent by
|
|
3
|
+
* fetching them from the public Test-Lab-ai/skills mirror. The mirror is the
|
|
4
|
+
* single source of truth, so this always installs the latest published skill.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
* .claude/skills/<name>/
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Each agent has its own convention (verified against current docs):
|
|
7
|
+
* claude → .claude/skills/<name>/SKILL.md (project) | ~/.claude/skills (--global)
|
|
8
|
+
* codex → .agents/skills/<name>/SKILL.md (project) | ~/.agents/skills (--global)
|
|
9
|
+
* cursor → .cursor/rules/<name>.mdc (project only — Cursor has no
|
|
10
|
+
* global *file*; user rules live in Settings)
|
|
11
|
+
*
|
|
12
|
+
* claude + codex use the identical SKILL.md folder format, so they share the
|
|
13
|
+
* write path (only the base dir differs). cursor uses a single .mdc rule file
|
|
14
|
+
* with its own frontmatter, so we convert SKILL.md on the way out.
|
|
15
|
+
*
|
|
16
|
+
* Zero-dep: global fetch + node:fs.
|
|
9
17
|
*/
|
|
10
18
|
import fs from "node:fs"
|
|
11
19
|
import os from "node:os"
|
|
@@ -13,19 +21,55 @@ import path from "node:path"
|
|
|
13
21
|
|
|
14
22
|
const MIRROR = "Test-Lab-ai/skills"
|
|
15
23
|
const MIRROR_BRANCH = "main"
|
|
24
|
+
const UA = { "User-Agent": "test-lab-cli" }
|
|
16
25
|
|
|
17
26
|
// Skills published by test-lab. Add new skill directory names here.
|
|
18
27
|
export const TESTLAB_SKILLS = ["test-lab-plan"]
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
// Agents this CLI can install into.
|
|
30
|
+
export const AGENTS = ["claude", "codex", "cursor"]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort detection of which agent(s) to install for when --agent is
|
|
34
|
+
* omitted. Combines two signals:
|
|
35
|
+
* (a) env markers of the agent running this command (CLAUDECODE is reliable;
|
|
36
|
+
* Codex/Cursor only expose sandbox-conditional vars, so absence means
|
|
37
|
+
* "unknown", never "no"), and
|
|
38
|
+
* (b) agent config dirs already present in the project.
|
|
39
|
+
* Returns a de-duplicated list (possibly empty).
|
|
40
|
+
*/
|
|
41
|
+
export function detectAgents() {
|
|
42
|
+
const env = process.env
|
|
43
|
+
const cwd = process.cwd()
|
|
44
|
+
const found = new Set()
|
|
45
|
+
if (env.CLAUDECODE) found.add("claude")
|
|
46
|
+
if (env.CODEX_SANDBOX) found.add("codex")
|
|
47
|
+
if (env.CURSOR_AGENT || env.CURSOR_TRACE_ID || env.CURSOR_SANDBOX) found.add("cursor")
|
|
48
|
+
if (fs.existsSync(path.join(cwd, ".claude"))) found.add("claude")
|
|
49
|
+
if (fs.existsSync(path.join(cwd, ".agents"))) found.add("codex")
|
|
50
|
+
if (fs.existsSync(path.join(cwd, ".cursor"))) found.add("cursor")
|
|
51
|
+
return [...found]
|
|
24
52
|
}
|
|
25
53
|
|
|
26
|
-
|
|
54
|
+
/** Resolve the install target (base dir + format) for one agent. */
|
|
55
|
+
export function agentTarget(agent, { global } = {}) {
|
|
56
|
+
const home = os.homedir()
|
|
57
|
+
const cwd = process.cwd()
|
|
58
|
+
switch (agent) {
|
|
59
|
+
case "claude":
|
|
60
|
+
return { kind: "skill-dir", base: global ? path.join(home, ".claude", "skills") : path.join(cwd, ".claude", "skills") }
|
|
61
|
+
case "codex":
|
|
62
|
+
return { kind: "skill-dir", base: global ? path.join(home, ".agents", "skills") : path.join(cwd, ".agents", "skills") }
|
|
63
|
+
case "cursor":
|
|
64
|
+
if (global) {
|
|
65
|
+
throw new Error("Cursor has no global rules file — add user rules in Cursor Settings → Rules, or install per-project (omit --global).")
|
|
66
|
+
}
|
|
67
|
+
return { kind: "cursor-rule", base: path.join(cwd, ".cursor", "rules") }
|
|
68
|
+
default:
|
|
69
|
+
throw new Error(`unknown agent "${agent}" (use ${AGENTS.join(", ")}, or all)`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
27
72
|
|
|
28
|
-
// List the blob paths under skills/<name>/ in the mirror's tree.
|
|
29
73
|
async function listSkillFiles(name) {
|
|
30
74
|
const r = await fetch(
|
|
31
75
|
`https://api.github.com/repos/${MIRROR}/git/trees/${MIRROR_BRANCH}?recursive=1`,
|
|
@@ -39,23 +83,47 @@ async function listSkillFiles(name) {
|
|
|
39
83
|
.map((e) => e.path)
|
|
40
84
|
}
|
|
41
85
|
|
|
42
|
-
|
|
43
|
-
|
|
86
|
+
async function fetchText(p) {
|
|
87
|
+
const r = await fetch(`https://raw.githubusercontent.com/${MIRROR}/${MIRROR_BRANCH}/${p}`, { headers: UA })
|
|
88
|
+
if (!r.ok) throw new Error(`could not fetch ${p} (${r.status})`)
|
|
89
|
+
return r.text()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Convert a Claude SKILL.md into a Cursor .mdc rule (Agent Requested mode). */
|
|
93
|
+
function toCursorRule(skillMd) {
|
|
94
|
+
const m = skillMd.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
|
|
95
|
+
const frontmatter = m ? m[1] : ""
|
|
96
|
+
const body = (m ? m[2] : skillMd).trim()
|
|
97
|
+
const descMatch = frontmatter.match(/^description:\s*(.+)$/m)
|
|
98
|
+
const description = descMatch ? descMatch[1].trim() : "Write test-lab.ai test plans"
|
|
99
|
+
// alwaysApply:false + a description => Cursor pulls it in when relevant.
|
|
100
|
+
return `---\ndescription: ${description}\nalwaysApply: false\n---\n\n${body}\n`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Install one skill for one agent. Returns { name, agent, count, dest }. */
|
|
104
|
+
export async function installSkill(name, agent, opts = {}) {
|
|
105
|
+
const { kind, base } = agentTarget(agent, opts)
|
|
44
106
|
const files = await listSkillFiles(name)
|
|
45
107
|
if (files.length === 0) throw new Error(`skill "${name}" not found in ${MIRROR}`)
|
|
46
108
|
const prefix = `skills/${name}/`
|
|
109
|
+
|
|
110
|
+
if (kind === "cursor-rule") {
|
|
111
|
+
const skillPath = files.find((p) => p.endsWith("/SKILL.md"))
|
|
112
|
+
if (!skillPath) throw new Error(`skill "${name}" has no SKILL.md`)
|
|
113
|
+
const dest = path.join(base, `${name}.mdc`)
|
|
114
|
+
fs.mkdirSync(base, { recursive: true })
|
|
115
|
+
fs.writeFileSync(dest, toCursorRule(await fetchText(skillPath)))
|
|
116
|
+
return { name, agent, count: 1, dest }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// skill-dir: claude + codex (identical SKILL.md folder layout).
|
|
47
120
|
let count = 0
|
|
48
121
|
for (const p of files) {
|
|
49
122
|
const rel = p.slice(prefix.length)
|
|
50
|
-
const
|
|
51
|
-
headers: UA,
|
|
52
|
-
})
|
|
53
|
-
if (!raw.ok) throw new Error(`could not fetch ${p} (${raw.status})`)
|
|
54
|
-
const body = await raw.text()
|
|
55
|
-
const dest = path.join(targetDir, name, rel)
|
|
123
|
+
const dest = path.join(base, name, rel)
|
|
56
124
|
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
57
|
-
fs.writeFileSync(dest,
|
|
125
|
+
fs.writeFileSync(dest, await fetchText(p))
|
|
58
126
|
count++
|
|
59
127
|
}
|
|
60
|
-
return { name, count,
|
|
128
|
+
return { name, agent, count, dest: path.join(base, name) }
|
|
61
129
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort "update available" notice.
|
|
3
|
+
*
|
|
4
|
+
* Design constraints: never block, slow, or break a command. It reads a cached
|
|
5
|
+
* latest-version (checked against the npm registry at most once/day), prints a
|
|
6
|
+
* one-line notice to STDERR when the running version is behind, and kicks off a
|
|
7
|
+
* background refresh for next time. Any failure (offline, slow/forbidden
|
|
8
|
+
* registry) is swallowed. Suppressed when stderr isn't a TTY (pipes / CI /
|
|
9
|
+
* agents) or NO_UPDATE_NOTIFIER / CI is set, so it never pollutes scripted output.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs"
|
|
12
|
+
import os from "node:os"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
|
|
15
|
+
const PKG = "@test-lab-ai/cli"
|
|
16
|
+
const REGISTRY = "https://registry.npmjs.org/@test-lab-ai%2Fcli"
|
|
17
|
+
const CACHE = path.join(os.homedir(), ".test-lab", "update-check.json")
|
|
18
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
export function currentVersion() {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Numeric major.minor.patch compare (pre-release tags ignored). true if a > b. */
|
|
29
|
+
export function isNewer(a, b) {
|
|
30
|
+
const parts = (v) => String(v || "0").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0)
|
|
31
|
+
const x = parts(a)
|
|
32
|
+
const y = parts(b)
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0)
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The notice string for (current, latest), or null if no update / bad input. */
|
|
40
|
+
export function updateNotice(current, latest) {
|
|
41
|
+
if (!current || !latest || !isNewer(latest, current)) return null
|
|
42
|
+
return `\n ⚡ Update available: ${current} → ${latest}\n npm i -g ${PKG}@latest\n`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCache() {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(CACHE, "utf8"))
|
|
48
|
+
} catch {
|
|
49
|
+
return {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeCache(obj) {
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(CACHE), { recursive: true })
|
|
56
|
+
fs.writeFileSync(CACHE, JSON.stringify(obj))
|
|
57
|
+
} catch {
|
|
58
|
+
/* best effort */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkForUpdate() {
|
|
63
|
+
if (!process.stderr.isTTY || process.env.NO_UPDATE_NOTIFIER || process.env.CI) return
|
|
64
|
+
const current = currentVersion()
|
|
65
|
+
if (!current) return
|
|
66
|
+
|
|
67
|
+
const cache = readCache()
|
|
68
|
+
|
|
69
|
+
// Notify from the last-known latest (this run); refresh updates next run.
|
|
70
|
+
const notice = updateNotice(current, cache.latest)
|
|
71
|
+
if (notice) process.stderr.write(notice + "\n")
|
|
72
|
+
|
|
73
|
+
if (!cache.lastCheck || Date.now() - cache.lastCheck > DAY) {
|
|
74
|
+
const ctrl = new AbortController()
|
|
75
|
+
const timer = setTimeout(() => ctrl.abort(), 2000)
|
|
76
|
+
fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/vnd.npm.install-v1+json" } })
|
|
77
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
78
|
+
.then((j) => {
|
|
79
|
+
const latest = j && j["dist-tags"] && j["dist-tags"].latest
|
|
80
|
+
if (latest) writeCache({ lastCheck: Date.now(), latest })
|
|
81
|
+
})
|
|
82
|
+
.catch(() => {})
|
|
83
|
+
.finally(() => clearTimeout(timer))
|
|
84
|
+
}
|
|
85
|
+
}
|