@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 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.NAME}}` placeholder, and collect the real values into the
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.FIXTURE.FIELD}}`
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.X}}`. Max 32 KB |
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.KEY}}`. Keys start with a
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 KEY --value V Set a credential ({{credentials.KEY}})
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.KEY.FIELD}})
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.KEY}}` — a stored secret (never shown to the AI model).
100
- - `{{data.FIXTURE.FIELD}}` — a value from a data fixture (generated test 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 so an AI agent can design a
106
- plan, then this CLI imports it:
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 # into ./.claude/skills (this project)
110
- testlab skills install --global # into ~/.claude/skills (all projects)
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
- 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.)
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.X}}`, generated data as `{{data.X.Y}}`) →
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 KEY --value V Upsert an account credential ({{credentials.KEY}})
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, resolveSkillsDir, installSkill } from "../lib/skills.mjs"
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 KEY --value V Set a credential ({{credentials.KEY}})
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.KEY.FIELD}})
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 [--global] Install the test-lab-plan skill into Claude Code
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
- dir: { type: "string" },
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
- 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}`)
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 Claude Code (start a new session) to load the skill.`)
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] [--global]`)
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
- return errExit("usage: testlab credentials set <key> --value <v>")
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.KEY}} a stored secret (never shown to the AI/model)
14
- {{data.FIXTURE.FIELD}} a value from a data fixture (generated test 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.KEY}}
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.KEY.FIELD}}
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's skills
3
- * directory by fetching them from the public Test-Lab-ai/skills mirror.
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
- * 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.
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
- 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")
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
- const UA = { "User-Agent": "test-lab-cli" }
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
- /** Fetch every file of one skill from the mirror and write it under targetDir/<name>/. */
43
- export async function installSkill(name, targetDir) {
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 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)
123
+ const dest = path.join(base, name, rel)
56
124
  fs.mkdirSync(path.dirname(dest), { recursive: true })
57
- fs.writeFileSync(dest, body)
125
+ fs.writeFileSync(dest, await fetchText(p))
58
126
  count++
59
127
  }
60
- return { name, count, dir: path.join(targetDir, name) }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-lab-ai/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.6",
4
4
  "description": "Import existing test plans into test-lab.ai from the command line (or an AI agent).",
5
5
  "type": "module",
6
6
  "bin": {