@test-lab-ai/cli 0.2.1 → 0.2.2
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 +13 -0
- package/bin/testlab.mjs +28 -0
- package/lib/login.mjs +3 -2
- package/lib/skills.mjs +61 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,6 +100,19 @@ in the file doesn't matter. Re-running is safe for credentials/labels/fixtures
|
|
|
100
100
|
- `{{data.FIXTURE.FIELD}}` — a value from a data fixture (generated test data).
|
|
101
101
|
- `{{run.shortId}}` — a unique per-run id (for unique emails, names, etc.).
|
|
102
102
|
|
|
103
|
+
## Install the test-lab-plan skill (Claude Code)
|
|
104
|
+
|
|
105
|
+
The companion skill that *writes* test-lab plans — so an AI agent can design a
|
|
106
|
+
plan, then this CLI imports it:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
testlab skills install # into ./.claude/skills (this project)
|
|
110
|
+
testlab skills install --global # into ~/.claude/skills (all projects)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Restart Claude Code to load it. It installs from the public skills mirror, so
|
|
114
|
+
you always get the latest version. (Targets Claude Code's `.claude/skills/` layout.)
|
|
115
|
+
|
|
103
116
|
## For AI agents
|
|
104
117
|
|
|
105
118
|
Run **`testlab examples`** for the complete JSON reference, or read `AGENTS.md`
|
package/bin/testlab.mjs
CHANGED
|
@@ -23,6 +23,7 @@ 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, resolveSkillsDir, installSkill } from "../lib/skills.mjs"
|
|
26
27
|
|
|
27
28
|
const log = (...a) => console.log(...a)
|
|
28
29
|
function errExit(msg) {
|
|
@@ -44,6 +45,7 @@ Usage:
|
|
|
44
45
|
testlab data create -f fixture.json Create a data fixture ({{data.KEY.FIELD}})
|
|
45
46
|
testlab examples Print the full JSON reference for every
|
|
46
47
|
resource (designed for AI agents)
|
|
48
|
+
testlab skills install [--global] Install the test-lab-plan skill into Claude Code
|
|
47
49
|
|
|
48
50
|
Options:
|
|
49
51
|
--key <tl_…> API key (else $TESTLAB_API_KEY or ~/.test-lab/config.json)
|
|
@@ -66,6 +68,8 @@ function parse() {
|
|
|
66
68
|
"dry-run": { type: "boolean" },
|
|
67
69
|
stdin: { type: "boolean" },
|
|
68
70
|
force: { type: "boolean" },
|
|
71
|
+
global: { type: "boolean" },
|
|
72
|
+
dir: { type: "string" },
|
|
69
73
|
help: { type: "boolean", short: "h" },
|
|
70
74
|
},
|
|
71
75
|
})
|
|
@@ -248,6 +252,26 @@ function cmdExamples() {
|
|
|
248
252
|
log(EXAMPLES_TEXT)
|
|
249
253
|
}
|
|
250
254
|
|
|
255
|
+
async function cmdSkillsInstall(flags, args) {
|
|
256
|
+
const requested = args[2] ? [args[2]] : TESTLAB_SKILLS
|
|
257
|
+
const targetDir = resolveSkillsDir({ global: flags.global, dir: flags.dir })
|
|
258
|
+
for (const name of requested) {
|
|
259
|
+
try {
|
|
260
|
+
const res = await installSkill(name, targetDir)
|
|
261
|
+
log(`✓ Installed ${res.name} (${res.count} file${res.count === 1 ? "" : "s"}) → ${res.dir}`)
|
|
262
|
+
} catch (e) {
|
|
263
|
+
errExit(`failed to install ${name}: ${e.message}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
log(`\nRestart Claude Code (start a new session) to load the skill.`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function cmdSkillsList() {
|
|
270
|
+
log(`Available test-lab skills:`)
|
|
271
|
+
for (const s of TESTLAB_SKILLS) log(` ${s}`)
|
|
272
|
+
log(`\nInstall with: testlab skills install [name] [--global]`)
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
async function main() {
|
|
252
276
|
let parsed
|
|
253
277
|
try {
|
|
@@ -281,6 +305,10 @@ async function main() {
|
|
|
281
305
|
return errExit("usage: testlab data <list|create>")
|
|
282
306
|
case "examples":
|
|
283
307
|
return cmdExamples()
|
|
308
|
+
case "skills":
|
|
309
|
+
if (args[1] === "install") return cmdSkillsInstall(flags, args)
|
|
310
|
+
if (args[1] === "list") return cmdSkillsList()
|
|
311
|
+
return errExit("usage: testlab skills <install|list> [name] [--global]")
|
|
284
312
|
case "import":
|
|
285
313
|
return cmdImport(flags, args)
|
|
286
314
|
default:
|
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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install test-lab skills (e.g. test-lab-plan) into a local agent's skills
|
|
3
|
+
* directory by fetching them from the public Test-Lab-ai/skills mirror.
|
|
4
|
+
*
|
|
5
|
+
* Zero-dep: global fetch + node:fs. Targets Claude Code's
|
|
6
|
+
* .claude/skills/<name>/ layout — project dir by default, ~/.claude with
|
|
7
|
+
* --global, or an explicit --dir. The mirror is the single source of truth,
|
|
8
|
+
* so this always installs the latest published skill.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs"
|
|
11
|
+
import os from "node:os"
|
|
12
|
+
import path from "node:path"
|
|
13
|
+
|
|
14
|
+
const MIRROR = "Test-Lab-ai/skills"
|
|
15
|
+
const MIRROR_BRANCH = "main"
|
|
16
|
+
|
|
17
|
+
// Skills published by test-lab. Add new skill directory names here.
|
|
18
|
+
export const TESTLAB_SKILLS = ["test-lab-plan"]
|
|
19
|
+
|
|
20
|
+
export function resolveSkillsDir({ global, dir } = {}) {
|
|
21
|
+
if (dir) return path.resolve(dir)
|
|
22
|
+
if (global) return path.join(os.homedir(), ".claude", "skills")
|
|
23
|
+
return path.join(process.cwd(), ".claude", "skills")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const UA = { "User-Agent": "test-lab-cli" }
|
|
27
|
+
|
|
28
|
+
// List the blob paths under skills/<name>/ in the mirror's tree.
|
|
29
|
+
async function listSkillFiles(name) {
|
|
30
|
+
const r = await fetch(
|
|
31
|
+
`https://api.github.com/repos/${MIRROR}/git/trees/${MIRROR_BRANCH}?recursive=1`,
|
|
32
|
+
{ headers: { ...UA, Accept: "application/vnd.github+json" } }
|
|
33
|
+
)
|
|
34
|
+
if (!r.ok) throw new Error(`could not list ${MIRROR} (${r.status})`)
|
|
35
|
+
const tree = await r.json()
|
|
36
|
+
const prefix = `skills/${name}/`
|
|
37
|
+
return (tree.tree || [])
|
|
38
|
+
.filter((e) => e.type === "blob" && typeof e.path === "string" && e.path.startsWith(prefix))
|
|
39
|
+
.map((e) => e.path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fetch every file of one skill from the mirror and write it under targetDir/<name>/. */
|
|
43
|
+
export async function installSkill(name, targetDir) {
|
|
44
|
+
const files = await listSkillFiles(name)
|
|
45
|
+
if (files.length === 0) throw new Error(`skill "${name}" not found in ${MIRROR}`)
|
|
46
|
+
const prefix = `skills/${name}/`
|
|
47
|
+
let count = 0
|
|
48
|
+
for (const p of files) {
|
|
49
|
+
const rel = p.slice(prefix.length)
|
|
50
|
+
const raw = await fetch(`https://raw.githubusercontent.com/${MIRROR}/${MIRROR_BRANCH}/${p}`, {
|
|
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)
|
|
56
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
57
|
+
fs.writeFileSync(dest, body)
|
|
58
|
+
count++
|
|
59
|
+
}
|
|
60
|
+
return { name, count, dir: path.join(targetDir, name) }
|
|
61
|
+
}
|