@test-lab-ai/cli 0.2.1 → 0.2.5
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 +32 -5
- package/bin/testlab.mjs +96 -4
- package/lib/examples.mjs +4 -4
- package/lib/login.mjs +3 -2
- package/lib/skills.mjs +129 -0
- 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,16 +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
|
|
|
105
|
+
## Install the test-lab-plan skill (Claude Code, Codex, Cursor)
|
|
106
|
+
|
|
107
|
+
The companion skill that *writes* test-lab plans, so an AI agent can design a
|
|
108
|
+
plan and this CLI imports it:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
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
|
|
115
|
+
```
|
|
116
|
+
|
|
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.
|
|
125
|
+
|
|
103
126
|
## For AI agents
|
|
104
127
|
|
|
105
128
|
Run **`testlab examples`** for the complete JSON reference, or read `AGENTS.md`
|
|
106
129
|
(shipped inside this package). The workflow: read the user's existing tests →
|
|
107
130
|
convert each into the plan/fixture JSON above (explicit URL in the prompt,
|
|
108
|
-
secrets as `{{credentials
|
|
131
|
+
secrets as `{{credentials.<key>}}`, generated data as `{{data.<fixture>.<field>}}`) →
|
|
109
132
|
`testlab import`.
|
|
110
133
|
|
|
111
134
|
## Under the hood
|
|
@@ -113,3 +136,7 @@ secrets as `{{credentials.X}}`, generated data as `{{data.X.Y}}`) →
|
|
|
113
136
|
Every command is a thin wrapper over the public
|
|
114
137
|
[Import API](https://test-lab.ai/docs/api/test-plans); an agent can call that
|
|
115
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,6 +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, AGENTS, detectAgents, installSkill } from "../lib/skills.mjs"
|
|
27
|
+
import { checkForUpdate } from "../lib/update-check.mjs"
|
|
26
28
|
|
|
27
29
|
const log = (...a) => console.log(...a)
|
|
28
30
|
function errExit(msg) {
|
|
@@ -39,11 +41,15 @@ Usage:
|
|
|
39
41
|
(credentials, labels, fixtures, plans)
|
|
40
42
|
testlab plans list List your test plans
|
|
41
43
|
testlab plans create -f plan.json Create one plan from JSON
|
|
42
|
-
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
|
|
43
47
|
testlab data list List your data fixtures
|
|
44
|
-
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>}})
|
|
45
49
|
testlab examples Print the full JSON reference for every
|
|
46
50
|
resource (designed for AI agents)
|
|
51
|
+
testlab skills install [--agent ...] Install the test-lab-plan skill (auto-detects
|
|
52
|
+
Claude/Codex/Cursor; --agent claude|codex|cursor|all)
|
|
47
53
|
|
|
48
54
|
Options:
|
|
49
55
|
--key <tl_…> API key (else $TESTLAB_API_KEY or ~/.test-lab/config.json)
|
|
@@ -66,6 +72,8 @@ function parse() {
|
|
|
66
72
|
"dry-run": { type: "boolean" },
|
|
67
73
|
stdin: { type: "boolean" },
|
|
68
74
|
force: { type: "boolean" },
|
|
75
|
+
global: { type: "boolean" },
|
|
76
|
+
agent: { type: "string" },
|
|
69
77
|
help: { type: "boolean", short: "h" },
|
|
70
78
|
},
|
|
71
79
|
})
|
|
@@ -191,6 +199,36 @@ async function cmdCredentialsSet(flags, args) {
|
|
|
191
199
|
log(`✓ Set credential ${key}`)
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
async function cmdCredentialsList(flags) {
|
|
203
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
204
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/credentials")
|
|
205
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
206
|
+
const creds = r.json?.credentials || []
|
|
207
|
+
if (creds.length === 0) {
|
|
208
|
+
log("No credentials yet.")
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
// Keys only; the API never returns credential values.
|
|
212
|
+
for (const c of creds) log(` ${c.key}`)
|
|
213
|
+
log(`\n${creds.length} credential(s)`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function cmdLabelsList(flags) {
|
|
217
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
218
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/labels")
|
|
219
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
220
|
+
const labels = r.json?.labels || []
|
|
221
|
+
if (labels.length === 0) {
|
|
222
|
+
log("No labels yet.")
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
for (const l of labels) {
|
|
226
|
+
const n = l.test_plan_count
|
|
227
|
+
log(` ${l.name}${n != null ? ` (${n} plan${n === 1 ? "" : "s"})` : ""}`)
|
|
228
|
+
}
|
|
229
|
+
log(`\n${labels.length} label(s)`)
|
|
230
|
+
}
|
|
231
|
+
|
|
194
232
|
async function cmdImport(flags, args) {
|
|
195
233
|
const target = args[1]
|
|
196
234
|
if (!target) errExit("usage: testlab import <path> [--dry-run]")
|
|
@@ -248,6 +286,50 @@ function cmdExamples() {
|
|
|
248
286
|
log(EXAMPLES_TEXT)
|
|
249
287
|
}
|
|
250
288
|
|
|
289
|
+
async function cmdSkillsInstall(flags, args) {
|
|
290
|
+
const requested = args[2] ? [args[2]] : TESTLAB_SKILLS
|
|
291
|
+
|
|
292
|
+
// --agent wins (a single agent, a comma list, or "all"); otherwise auto-detect
|
|
293
|
+
// from the running agent + the project's agent dirs, falling back to Claude.
|
|
294
|
+
let targets
|
|
295
|
+
if (flags.agent) {
|
|
296
|
+
const v = String(flags.agent).toLowerCase()
|
|
297
|
+
targets = v === "all" ? AGENTS : v.split(",").map((s) => s.trim()).filter(Boolean)
|
|
298
|
+
} else {
|
|
299
|
+
targets = detectAgents()
|
|
300
|
+
if (targets.length === 0) {
|
|
301
|
+
targets = ["claude"]
|
|
302
|
+
log("No agent detected; defaulting to Claude Code. Use --agent claude|codex|cursor|all to choose.")
|
|
303
|
+
} else {
|
|
304
|
+
log(`Detected: ${targets.join(", ")} (override with --agent, add --global for your home dir)`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const bad = targets.filter((a) => !AGENTS.includes(a))
|
|
308
|
+
if (bad.length) errExit(`unknown agent(s): ${bad.join(", ")}. Use ${AGENTS.join(", ")}, or all.`)
|
|
309
|
+
|
|
310
|
+
let installed = 0
|
|
311
|
+
for (const agent of targets) {
|
|
312
|
+
for (const name of requested) {
|
|
313
|
+
try {
|
|
314
|
+
const res = await installSkill(name, agent, { global: flags.global })
|
|
315
|
+
installed++
|
|
316
|
+
log(`✓ ${res.name} → ${agent}: ${res.dest}`)
|
|
317
|
+
} catch (e) {
|
|
318
|
+
// When installing for several agents, one failing (e.g. cursor + --global) must not abort the rest.
|
|
319
|
+
if (targets.length > 1) log(` skipped ${agent}: ${e.message}`)
|
|
320
|
+
else errExit(`failed to install ${name} for ${agent}: ${e.message}`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (installed > 0) log(`\nRestart your agent (start a new session) to load the skill.`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function cmdSkillsList() {
|
|
328
|
+
log(`Available test-lab skills:`)
|
|
329
|
+
for (const s of TESTLAB_SKILLS) log(` ${s}`)
|
|
330
|
+
log(`\nInstall with: testlab skills install [name] [--agent ${AGENTS.join("|")}|all]`)
|
|
331
|
+
}
|
|
332
|
+
|
|
251
333
|
async function main() {
|
|
252
334
|
let parsed
|
|
253
335
|
try {
|
|
@@ -258,6 +340,8 @@ async function main() {
|
|
|
258
340
|
const flags = parsed.values
|
|
259
341
|
const args = parsed.positionals
|
|
260
342
|
|
|
343
|
+
checkForUpdate() // best-effort "update available" notice (cached, non-blocking)
|
|
344
|
+
|
|
261
345
|
if (flags.help || args.length === 0 || args[0] === "help") {
|
|
262
346
|
log(HELP)
|
|
263
347
|
return
|
|
@@ -274,13 +358,21 @@ async function main() {
|
|
|
274
358
|
return errExit("usage: testlab plans <list|create>")
|
|
275
359
|
case "credentials":
|
|
276
360
|
if (args[1] === "set") return cmdCredentialsSet(flags, args)
|
|
277
|
-
|
|
361
|
+
if (args[1] === "list") return cmdCredentialsList(flags)
|
|
362
|
+
return errExit("usage: testlab credentials <set|list>")
|
|
363
|
+
case "labels":
|
|
364
|
+
if (args[1] === "list") return cmdLabelsList(flags)
|
|
365
|
+
return errExit("usage: testlab labels list")
|
|
278
366
|
case "data":
|
|
279
367
|
if (args[1] === "list") return cmdDataList(flags)
|
|
280
368
|
if (args[1] === "create") return cmdDataCreate(flags)
|
|
281
369
|
return errExit("usage: testlab data <list|create>")
|
|
282
370
|
case "examples":
|
|
283
371
|
return cmdExamples()
|
|
372
|
+
case "skills":
|
|
373
|
+
if (args[1] === "install") return cmdSkillsInstall(flags, args)
|
|
374
|
+
if (args[1] === "list") return cmdSkillsList()
|
|
375
|
+
return errExit("usage: testlab skills <install|list> [name] [--global]")
|
|
284
376
|
case "import":
|
|
285
377
|
return cmdImport(flags, args)
|
|
286
378
|
default:
|
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/login.mjs
CHANGED
|
@@ -38,7 +38,7 @@ function openBrowser(url) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function browserLogin(apiUrl, { timeoutMs =
|
|
41
|
+
export function browserLogin(apiUrl, { timeoutMs = 600000 } = {}) {
|
|
42
42
|
const state = crypto.randomBytes(16).toString("hex")
|
|
43
43
|
|
|
44
44
|
return new Promise((resolve, reject) => {
|
|
@@ -80,13 +80,14 @@ export function browserLogin(apiUrl, { timeoutMs = 180000 } = {}) {
|
|
|
80
80
|
|
|
81
81
|
const timer = setTimeout(() => {
|
|
82
82
|
server.close()
|
|
83
|
-
reject(new Error("login timed out after
|
|
83
|
+
reject(new Error("login timed out after 10 minutes"))
|
|
84
84
|
}, timeoutMs)
|
|
85
85
|
|
|
86
86
|
server.listen(0, "127.0.0.1", () => {
|
|
87
87
|
const { port } = server.address()
|
|
88
88
|
const authUrl = `${apiUrl}/cli/authorize?state=${state}&port=${port}`
|
|
89
89
|
console.log(`Opening your browser to authorize the CLI:\n ${authUrl}\n`)
|
|
90
|
+
console.log(`Waiting for you to approve in the browser (up to 10 minutes)…\n`)
|
|
90
91
|
openBrowser(authUrl)
|
|
91
92
|
})
|
|
92
93
|
})
|
package/lib/skills.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs"
|
|
19
|
+
import os from "node:os"
|
|
20
|
+
import path from "node:path"
|
|
21
|
+
|
|
22
|
+
const MIRROR = "Test-Lab-ai/skills"
|
|
23
|
+
const MIRROR_BRANCH = "main"
|
|
24
|
+
const UA = { "User-Agent": "test-lab-cli" }
|
|
25
|
+
|
|
26
|
+
// Skills published by test-lab. Add new skill directory names here.
|
|
27
|
+
export const TESTLAB_SKILLS = ["test-lab-plan"]
|
|
28
|
+
|
|
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]
|
|
52
|
+
}
|
|
53
|
+
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
async function listSkillFiles(name) {
|
|
74
|
+
const r = await fetch(
|
|
75
|
+
`https://api.github.com/repos/${MIRROR}/git/trees/${MIRROR_BRANCH}?recursive=1`,
|
|
76
|
+
{ headers: { ...UA, Accept: "application/vnd.github+json" } }
|
|
77
|
+
)
|
|
78
|
+
if (!r.ok) throw new Error(`could not list ${MIRROR} (${r.status})`)
|
|
79
|
+
const tree = await r.json()
|
|
80
|
+
const prefix = `skills/${name}/`
|
|
81
|
+
return (tree.tree || [])
|
|
82
|
+
.filter((e) => e.type === "blob" && typeof e.path === "string" && e.path.startsWith(prefix))
|
|
83
|
+
.map((e) => e.path)
|
|
84
|
+
}
|
|
85
|
+
|
|
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)
|
|
106
|
+
const files = await listSkillFiles(name)
|
|
107
|
+
if (files.length === 0) throw new Error(`skill "${name}" not found in ${MIRROR}`)
|
|
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).
|
|
120
|
+
let count = 0
|
|
121
|
+
for (const p of files) {
|
|
122
|
+
const rel = p.slice(prefix.length)
|
|
123
|
+
const dest = path.join(base, name, rel)
|
|
124
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
125
|
+
fs.writeFileSync(dest, await fetchText(p))
|
|
126
|
+
count++
|
|
127
|
+
}
|
|
128
|
+
return { name, agent, count, dest: path.join(base, name) }
|
|
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
|
+
}
|