@xera-ai/cli 0.13.0 → 0.13.1

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.
Files changed (2) hide show
  1. package/dist/index.js +436 -178
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -7,18 +7,265 @@ import { cac } from "cac";
7
7
  import pc4 from "picocolors";
8
8
 
9
9
  // src/commands/doctor.ts
10
- import { existsSync as existsSync2 } from "fs";
10
+ import { existsSync as existsSync6 } from "fs";
11
11
  import { NdjsonLogger, resolveArtifactPaths } from "@xera-ai/core";
12
12
  import pc from "picocolors";
13
13
 
14
14
  // src/checks.ts
15
- import { existsSync, readFileSync } from "fs";
16
- import { join } from "path";
15
+ import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
16
+ import { join as join5 } from "path";
17
17
  import { loadConfig, readAuthState } from "@xera-ai/core";
18
18
  import { parse as parseYaml } from "yaml";
19
+
20
+ // src/editors/claude.ts
21
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
22
+ import { dirname, join } from "path";
23
+
24
+ // src/editors/frontmatter.ts
25
+ var FENCE = "---";
26
+ function parseFrontmatter(md) {
27
+ if (!md.startsWith(`${FENCE}
28
+ `)) {
29
+ return { frontmatter: { raw: "", fields: {} }, body: md };
30
+ }
31
+ const closeIdx = md.indexOf(`
32
+ ${FENCE}
33
+ `, FENCE.length + 1);
34
+ if (closeIdx < 0) {
35
+ return { frontmatter: { raw: "", fields: {} }, body: md };
36
+ }
37
+ const raw = md.slice(FENCE.length + 1, closeIdx);
38
+ const body = md.slice(closeIdx + `
39
+ ${FENCE}
40
+ `.length);
41
+ const fields = {};
42
+ const lines = raw.split(`
43
+ `);
44
+ for (let i = 0;i < lines.length; i++) {
45
+ const line = lines[i];
46
+ if (!line.trim())
47
+ continue;
48
+ const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
49
+ if (!m)
50
+ continue;
51
+ const key = m[1];
52
+ const value = m[2];
53
+ if (value === "|") {
54
+ const collected = [];
55
+ while (i + 1 < lines.length) {
56
+ const next = lines[i + 1];
57
+ if (/^ {2,}/.test(next)) {
58
+ collected.push(next.replace(/^ {2}/, ""));
59
+ i++;
60
+ } else if (next.trim() === "") {
61
+ collected.push("");
62
+ i++;
63
+ } else {
64
+ break;
65
+ }
66
+ }
67
+ while (collected.length && collected[collected.length - 1] === "")
68
+ collected.pop();
69
+ fields[key] = collected.join(`
70
+ `);
71
+ } else if (value === "true" || value === "false") {
72
+ fields[key] = value === "true";
73
+ } else if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
74
+ fields[key] = value.slice(1, -1).replace(/\\"/g, '"');
75
+ } else {
76
+ fields[key] = value;
77
+ }
78
+ }
79
+ return { frontmatter: { raw, fields }, body };
80
+ }
81
+ function serializeFrontmatter(fields) {
82
+ const lines = [FENCE];
83
+ for (const [key, value] of Object.entries(fields)) {
84
+ if (typeof value === "boolean") {
85
+ lines.push(`${key}: ${value}`);
86
+ } else if (Array.isArray(value)) {
87
+ lines.push(`${key}: [${value.join(", ")}]`);
88
+ } else if (value.includes(`
89
+ `)) {
90
+ lines.push(`${key}: |`);
91
+ for (const sub of value.split(`
92
+ `))
93
+ lines.push(` ${sub}`);
94
+ } else if (/[:#]/.test(value)) {
95
+ lines.push(`${key}: "${value.replace(/"/g, "\\\"")}"`);
96
+ } else {
97
+ lines.push(`${key}: ${value}`);
98
+ }
99
+ }
100
+ lines.push(FENCE);
101
+ return `${lines.join(`
102
+ `)}
103
+ `;
104
+ }
105
+
106
+ // src/editors/claude.ts
107
+ function renderSource(input) {
108
+ return serializeFrontmatter(input.frontmatter.fields) + input.body;
109
+ }
110
+ var claudeAdapter = {
111
+ name: "claude",
112
+ detect(cwd) {
113
+ return existsSync(join(cwd, ".claude"));
114
+ },
115
+ scaffoldSkill(cwd, input) {
116
+ const target = join(cwd, ".claude/skills", input.base, "SKILL.md");
117
+ mkdirSync(dirname(target), { recursive: true });
118
+ writeFileSync(target, renderSource(input));
119
+ },
120
+ scaffoldCommand(cwd, input) {
121
+ const target = join(cwd, ".claude/commands", `${input.base}.md`);
122
+ mkdirSync(dirname(target), { recursive: true });
123
+ writeFileSync(target, renderSource(input));
124
+ },
125
+ legacyMigrate(cwd, base) {
126
+ const flat = join(cwd, ".claude/skills", `${base}.md`);
127
+ const dir = join(cwd, ".claude/skills", base, "SKILL.md");
128
+ if (!existsSync(flat) || existsSync(dir))
129
+ return false;
130
+ const content = readFileSync(flat);
131
+ mkdirSync(dirname(dir), { recursive: true });
132
+ writeFileSync(dir, content);
133
+ unlinkSync(flat);
134
+ return true;
135
+ },
136
+ doctorChecks(cwd, requiredSkills) {
137
+ const skillsDir = join(cwd, ".claude/skills");
138
+ if (!existsSync(skillsDir)) {
139
+ return [{ name: "xera skills present (claude)", ok: false, message: "run `xera init`" }];
140
+ }
141
+ const missing = [];
142
+ const legacyFlat = [];
143
+ for (const base of requiredSkills) {
144
+ if (existsSync(join(skillsDir, base, "SKILL.md")))
145
+ continue;
146
+ if (existsSync(join(skillsDir, `${base}.md`)))
147
+ legacyFlat.push(base);
148
+ else
149
+ missing.push(base);
150
+ }
151
+ const check = {
152
+ name: "xera skills present (claude)",
153
+ ok: missing.length === 0 && legacyFlat.length === 0
154
+ };
155
+ if (missing.length) {
156
+ check.message = `missing: ${missing.map((b) => `${b}/SKILL.md`).join(", ")}`;
157
+ } else if (legacyFlat.length) {
158
+ check.message = `legacy flat layout \u2014 run \`xera init --update --editor claude\` to migrate`;
159
+ }
160
+ return [check];
161
+ }
162
+ };
163
+
164
+ // src/editors/codex.ts
165
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
166
+ import { dirname as dirname2, join as join2 } from "path";
167
+ var codexAdapter = {
168
+ name: "codex",
169
+ detect(cwd) {
170
+ return existsSync2(join2(cwd, ".agents"));
171
+ },
172
+ scaffoldSkill(cwd, input) {
173
+ const target = join2(cwd, ".agents/skills", input.base, "SKILL.md");
174
+ mkdirSync2(dirname2(target), { recursive: true });
175
+ writeFileSync2(target, serializeFrontmatter(input.frontmatter.fields) + input.body);
176
+ },
177
+ doctorChecks(cwd, requiredSkills) {
178
+ const skillsDir = join2(cwd, ".agents/skills");
179
+ const missing = requiredSkills.filter((b) => !existsSync2(join2(skillsDir, b, "SKILL.md")));
180
+ const check = {
181
+ name: "xera skills present (codex)",
182
+ ok: missing.length === 0
183
+ };
184
+ if (missing.length) {
185
+ check.message = `missing: ${missing.map((b) => `${b}/SKILL.md`).join(", ")} \u2014 run \`xera init --update --editor codex\``;
186
+ }
187
+ return [check];
188
+ }
189
+ };
190
+
191
+ // src/editors/cursor.ts
192
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
193
+ import { dirname as dirname3, join as join3 } from "path";
194
+ function ruleFrontmatter(input) {
195
+ const desc = input.frontmatter.fields.description;
196
+ if (desc === undefined) {
197
+ throw new Error(`Cursor scaffold requires 'description' in source frontmatter for ${input.base}`);
198
+ }
199
+ return { description: desc, alwaysApply: false };
200
+ }
201
+ function commandFrontmatter(input) {
202
+ const desc = input.frontmatter.fields.description;
203
+ if (desc === undefined) {
204
+ throw new Error(`Cursor scaffold requires 'description' in source frontmatter for ${input.base}`);
205
+ }
206
+ return { description: desc };
207
+ }
208
+ function write(target, content) {
209
+ mkdirSync3(dirname3(target), { recursive: true });
210
+ writeFileSync3(target, content);
211
+ }
212
+ var cursorAdapter = {
213
+ name: "cursor",
214
+ detect(cwd) {
215
+ return existsSync3(join3(cwd, ".cursor"));
216
+ },
217
+ scaffoldSkill(cwd, input) {
218
+ const path = join3(cwd, ".cursor/rules", input.base, "RULE.md");
219
+ write(path, serializeFrontmatter(ruleFrontmatter(input)) + input.body);
220
+ },
221
+ scaffoldCommand(cwd, input) {
222
+ const path = join3(cwd, ".cursor/commands", `${input.base}.md`);
223
+ write(path, serializeFrontmatter(commandFrontmatter(input)) + input.body);
224
+ },
225
+ doctorChecks(cwd, requiredSkills) {
226
+ const rulesDir = join3(cwd, ".cursor/rules");
227
+ const cmdsDir = join3(cwd, ".cursor/commands");
228
+ const missing = [];
229
+ for (const base of requiredSkills) {
230
+ if (!existsSync3(join3(rulesDir, base, "RULE.md")))
231
+ missing.push(`${base}/RULE.md`);
232
+ if (!existsSync3(join3(cmdsDir, `${base}.md`)))
233
+ missing.push(`commands/${base}.md`);
234
+ }
235
+ const check = {
236
+ name: "xera skills present (cursor)",
237
+ ok: missing.length === 0
238
+ };
239
+ if (missing.length)
240
+ check.message = `missing: ${missing.join(", ")} \u2014 run \`xera init --update --editor cursor\``;
241
+ return [check];
242
+ }
243
+ };
244
+
245
+ // src/editors/index.ts
246
+ var ALL_EDITORS = ["claude", "cursor", "codex"];
247
+ var editors = {
248
+ claude: claudeAdapter,
249
+ cursor: cursorAdapter,
250
+ codex: codexAdapter
251
+ };
252
+
253
+ // src/editors/detect.ts
254
+ import { existsSync as existsSync4 } from "fs";
255
+ import { join as join4 } from "path";
256
+ var MARKERS = {
257
+ claude: ".claude",
258
+ cursor: ".cursor",
259
+ codex: ".agents"
260
+ };
261
+ function detectEditors(cwd) {
262
+ return ALL_EDITORS.filter((name) => existsSync4(join4(cwd, MARKERS[name])));
263
+ }
264
+
265
+ // src/checks.ts
19
266
  function pushTicketChecks(checks, cwd, ticket, acFieldConfigured) {
20
- const ticketDir = join(cwd, ".xera", ticket);
21
- if (!existsSync(ticketDir)) {
267
+ const ticketDir = join5(cwd, ".xera", ticket);
268
+ if (!existsSync5(ticketDir)) {
22
269
  checks.push({
23
270
  name: `${ticket}: .xera/${ticket}/ exists`,
24
271
  ok: false,
@@ -26,8 +273,8 @@ function pushTicketChecks(checks, cwd, ticket, acFieldConfigured) {
26
273
  });
27
274
  return;
28
275
  }
29
- const giPath = join(ticketDir, "graph-input.json");
30
- if (!existsSync(giPath)) {
276
+ const giPath = join5(ticketDir, "graph-input.json");
277
+ if (!existsSync5(giPath)) {
31
278
  checks.push({
32
279
  name: `${ticket}: graph-input.json present`,
33
280
  ok: false,
@@ -35,7 +282,7 @@ function pushTicketChecks(checks, cwd, ticket, acFieldConfigured) {
35
282
  });
36
283
  } else {
37
284
  try {
38
- const data = JSON.parse(readFileSync(giPath, "utf8"));
285
+ const data = JSON.parse(readFileSync2(giPath, "utf8"));
39
286
  if (!Array.isArray(data.modifiesAreas)) {
40
287
  checks.push({
41
288
  name: `${ticket}: graph-input.json present`,
@@ -57,8 +304,8 @@ function pushTicketChecks(checks, cwd, ticket, acFieldConfigured) {
57
304
  });
58
305
  }
59
306
  }
60
- const storyPath = join(ticketDir, "story.md");
61
- if (!existsSync(storyPath)) {
307
+ const storyPath = join5(ticketDir, "story.md");
308
+ if (!existsSync5(storyPath)) {
62
309
  checks.push({
63
310
  name: `${ticket}: story.md acceptanceCriteria`,
64
311
  ok: false,
@@ -66,7 +313,7 @@ function pushTicketChecks(checks, cwd, ticket, acFieldConfigured) {
66
313
  });
67
314
  return;
68
315
  }
69
- const raw = readFileSync(storyPath, "utf8");
316
+ const raw = readFileSync2(storyPath, "utf8");
70
317
  const m = raw.match(/^---\n([\s\S]*?)\n---/);
71
318
  if (!m) {
72
319
  checks.push({
@@ -162,10 +409,10 @@ async function runChecks(cwd, opts = {}) {
162
409
  });
163
410
  }
164
411
  }
165
- const httpAuthDir = join(cwd, ".xera", ".auth", "http");
412
+ const httpAuthDir = join5(cwd, ".xera", ".auth", "http");
166
413
  for (const role of Object.keys(cfg.http.auth.roles)) {
167
- const filePath = join(httpAuthDir, `${role}.json`);
168
- if (!existsSync(filePath)) {
414
+ const filePath = join5(httpAuthDir, `${role}.json`);
415
+ if (!existsSync5(filePath)) {
169
416
  checks.push({
170
417
  name: `http auth file present: ${role}`,
171
418
  ok: false,
@@ -224,7 +471,7 @@ async function runChecks(cwd, opts = {}) {
224
471
  });
225
472
  }
226
473
  } else {
227
- const open = existsSync(join(cwd, spec));
474
+ const open = existsSync5(join5(cwd, spec));
228
475
  const openCheck = open ? { name: "OpenAPI spec file present", ok: true } : { name: "OpenAPI spec file present", ok: false, message: `not found at ${spec}` };
229
476
  checks.push(openCheck);
230
477
  }
@@ -243,10 +490,10 @@ async function runChecks(cwd, opts = {}) {
243
490
  message: `${cfg.coverage.staleAfterDays}d is a very large window \u2014 coverage will be slow to react to drift`
244
491
  });
245
492
  }
246
- const snapPath = join(cwd, ".xera/graph/snapshot.json");
247
- if (existsSync(snapPath) && cfg.coverage.criticalAreas.length > 0) {
493
+ const snapPath = join5(cwd, ".xera/graph/snapshot.json");
494
+ if (existsSync5(snapPath) && cfg.coverage.criticalAreas.length > 0) {
248
495
  try {
249
- const snap = JSON.parse(readFileSync(snapPath, "utf8"));
496
+ const snap = JSON.parse(readFileSync2(snapPath, "utf8"));
250
497
  const known = new Set(Object.keys(snap.areas ?? {}));
251
498
  for (const slug of cfg.coverage.criticalAreas) {
252
499
  if (!known.has(slug)) {
@@ -259,9 +506,9 @@ async function runChecks(cwd, opts = {}) {
259
506
  }
260
507
  } catch {}
261
508
  }
262
- if (existsSync(snapPath)) {
509
+ if (existsSync5(snapPath)) {
263
510
  try {
264
- const snap = JSON.parse(readFileSync(snapPath, "utf8"));
511
+ const snap = JSON.parse(readFileSync2(snapPath, "utf8"));
265
512
  const acByTicket = {};
266
513
  for (const node of Object.values(snap.acNodes ?? {})) {
267
514
  acByTicket[node.ticketId] = (acByTicket[node.ticketId] ?? 0) + 1;
@@ -289,11 +536,11 @@ async function runChecks(cwd, opts = {}) {
289
536
  message: String(e.message)
290
537
  });
291
538
  }
292
- const envPath = join(cwd, ".env");
293
- if (!existsSync(envPath)) {
539
+ const envPath = join5(cwd, ".env");
540
+ if (!existsSync5(envPath)) {
294
541
  checks.push({ name: ".env present", ok: false, message: "copy from .env.example" });
295
542
  } else {
296
- const env = readFileSync(envPath, "utf8");
543
+ const env = readFileSync2(envPath, "utf8");
297
544
  checks.push({ name: "XERA_AUTH_KEY set", ok: /XERA_AUTH_KEY=[0-9a-fA-F]{64}/.test(env) });
298
545
  }
299
546
  try {
@@ -306,40 +553,26 @@ async function runChecks(cwd, opts = {}) {
306
553
  message: "run: bun add -D @playwright/test"
307
554
  });
308
555
  }
309
- const skillsDir = join(cwd, ".claude/skills");
310
- if (!existsSync(skillsDir)) {
311
- checks.push({ name: "xera skills present", ok: false, message: "run `xera init`" });
556
+ const REQUIRED_SKILLS = [
557
+ "xera-run",
558
+ "xera-fetch",
559
+ "xera-feature",
560
+ "xera-script",
561
+ "xera-exec",
562
+ "xera-report",
563
+ "xera-promote"
564
+ ];
565
+ const detected = detectEditors(cwd);
566
+ if (detected.length === 0) {
567
+ checks.push({
568
+ name: "xera editor integration present",
569
+ ok: false,
570
+ message: "run `xera init` (scaffolds for Claude Code, Cursor, and/or Codex)"
571
+ });
312
572
  } else {
313
- const required = [
314
- "xera-run",
315
- "xera-fetch",
316
- "xera-feature",
317
- "xera-script",
318
- "xera-exec",
319
- "xera-report",
320
- "xera-promote"
321
- ];
322
- const missing = [];
323
- const legacyFlat = [];
324
- for (const base of required) {
325
- if (existsSync(join(skillsDir, base, "SKILL.md")))
326
- continue;
327
- if (existsSync(join(skillsDir, `${base}.md`))) {
328
- legacyFlat.push(base);
329
- } else {
330
- missing.push(base);
331
- }
573
+ for (const name of detected) {
574
+ checks.push(...editors[name].doctorChecks(cwd, REQUIRED_SKILLS));
332
575
  }
333
- const skillsCheck = {
334
- name: "xera skills present",
335
- ok: missing.length === 0 && legacyFlat.length === 0
336
- };
337
- if (missing.length) {
338
- skillsCheck.message = `missing: ${missing.map((b) => `${b}/SKILL.md`).join(", ")}`;
339
- } else if (legacyFlat.length) {
340
- skillsCheck.message = `legacy flat layout in .claude/skills/ \u2014 run \`xera init --update\` to migrate to <name>/SKILL.md (Claude Code Skill tool requires the directory layout)`;
341
- }
342
- checks.push(skillsCheck);
343
576
  }
344
577
  return checks;
345
578
  }
@@ -349,7 +582,7 @@ async function doctorCommand(opts) {
349
582
  const cwd = process.cwd();
350
583
  if (opts.logs) {
351
584
  const paths = resolveArtifactPaths(cwd, opts.logs);
352
- if (!existsSync2(paths.logPath)) {
585
+ if (!existsSync6(paths.logPath)) {
353
586
  console.log(`No log at ${paths.logPath}`);
354
587
  return 0;
355
588
  }
@@ -375,25 +608,47 @@ async function doctorCommand(opts) {
375
608
  }
376
609
 
377
610
  // src/commands/init.ts
378
- import {
379
- appendFileSync,
380
- existsSync as existsSync3,
381
- mkdirSync as mkdirSync2,
382
- readdirSync as readdirSync2,
383
- readFileSync as readFileSync3,
384
- writeFileSync as writeFileSync2
385
- } from "fs";
611
+ import { appendFileSync, existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
386
612
  import { createRequire } from "module";
387
- import { dirname as dirname2, join as join3 } from "path";
613
+ import { join as join7 } from "path";
388
614
  import * as p from "@clack/prompts";
389
615
  import { generateKey } from "@xera-ai/core";
390
616
  import pc2 from "picocolors";
391
617
 
618
+ // src/editors/resolve.ts
619
+ function parseFlag(flag) {
620
+ if (flag === "all")
621
+ return [...ALL_EDITORS];
622
+ const parts = flag.split(",").map((s) => s.trim()).filter(Boolean);
623
+ if (parts.length === 0) {
624
+ throw new Error(`--editor: empty value. Valid: ${ALL_EDITORS.join(", ")} (or 'all').`);
625
+ }
626
+ const bad = parts.filter((p) => !ALL_EDITORS.includes(p));
627
+ if (bad.length) {
628
+ throw new Error(`--editor: unknown value(s) [${bad.join(", ")}]. Valid: ${ALL_EDITORS.join(", ")} (or 'all').`);
629
+ }
630
+ return parts;
631
+ }
632
+ async function resolveEditors(opts) {
633
+ if (opts.flag !== undefined)
634
+ return parseFlag(opts.flag);
635
+ const detected = detectEditors(opts.cwd);
636
+ if (opts.isUpdate)
637
+ return detected;
638
+ if (detected.length > 0)
639
+ return detected;
640
+ if (opts.isYes)
641
+ return [...ALL_EDITORS];
642
+ if (opts.prompt)
643
+ return opts.prompt();
644
+ return [...ALL_EDITORS];
645
+ }
646
+
392
647
  // src/scaffold.ts
393
- import { mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
394
- import { dirname, join as join2 } from "path";
648
+ import { mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
649
+ import { dirname as dirname4, join as join6 } from "path";
395
650
  import { fileURLToPath } from "url";
396
- var TEMPLATE_ROOT = join2(fileURLToPath(import.meta.url), "..", "..", "templates");
651
+ var TEMPLATE_ROOT = join6(fileURLToPath(import.meta.url), "..", "..", "templates");
397
652
  function render(tmpl, vars) {
398
653
  let out = tmpl;
399
654
  out = out.replace(/\{\{#each (\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (_, k, block) => {
@@ -410,9 +665,9 @@ function render(tmpl, vars) {
410
665
  return out;
411
666
  }
412
667
  function scaffoldFile(targetPath, templateName, vars) {
413
- const tmpl = readFileSync2(join2(TEMPLATE_ROOT, templateName), "utf8");
414
- mkdirSync(dirname(targetPath), { recursive: true });
415
- writeFileSync(targetPath, render(tmpl, vars));
668
+ const tmpl = readFileSync3(join6(TEMPLATE_ROOT, templateName), "utf8");
669
+ mkdirSync4(dirname4(targetPath), { recursive: true });
670
+ writeFileSync4(targetPath, render(tmpl, vars));
416
671
  }
417
672
 
418
673
  // src/commands/init.ts
@@ -517,26 +772,26 @@ async function initCommand(opts) {
517
772
  authKey: generateKey()
518
773
  };
519
774
  const configTmpl = shape === "web" ? "xera.config.ts.tmpl" : shape === "api" ? "http-xera.config.ts.tmpl" : "mixed-xera.config.ts.tmpl";
520
- scaffoldFile(join3(cwd, "xera.config.ts"), configTmpl, vars);
775
+ scaffoldFile(join7(cwd, "xera.config.ts"), configTmpl, vars);
521
776
  const pwTmpl = shape === "api" ? "http-playwright.config.ts.tmpl" : "playwright.config.ts.tmpl";
522
- scaffoldFile(join3(cwd, "playwright.config.ts"), pwTmpl, vars);
523
- scaffoldFile(join3(cwd, "tsconfig.json"), "tsconfig.json.tmpl", vars);
777
+ scaffoldFile(join7(cwd, "playwright.config.ts"), pwTmpl, vars);
778
+ scaffoldFile(join7(cwd, "tsconfig.json"), "tsconfig.json.tmpl", vars);
524
779
  if (shape === "api") {
525
- scaffoldFile(join3(cwd, ".env.example"), "http-env.example.tmpl", vars);
780
+ scaffoldFile(join7(cwd, ".env.example"), "http-env.example.tmpl", vars);
526
781
  } else {
527
- scaffoldFile(join3(cwd, ".env.example"), "env.example.tmpl", vars);
782
+ scaffoldFile(join7(cwd, ".env.example"), "env.example.tmpl", vars);
528
783
  }
529
784
  if (wantsWeb || wantsHttp) {
530
- scaffoldFile(join3(cwd, "shared/auth-setup.ts"), "auth-setup.ts.tmpl", vars);
785
+ scaffoldFile(join7(cwd, "shared/auth-setup.ts"), "auth-setup.ts.tmpl", vars);
531
786
  }
532
- scaffoldFile(join3(cwd, ".github/workflows/xera-graph.yml"), "xera-graph.yml.template", vars);
787
+ scaffoldFile(join7(cwd, ".github/workflows/xera-graph.yml"), "xera-graph.yml.template", vars);
533
788
  if (wantsHttp && vars.openapiPath && !vars.openapiPath.startsWith("http")) {
534
- const openapiTarget = join3(cwd, vars.openapiPath);
535
- if (!existsSync3(openapiTarget)) {
789
+ const openapiTarget = join7(cwd, vars.openapiPath);
790
+ if (!existsSync7(openapiTarget)) {
536
791
  scaffoldFile(openapiTarget, "openapi.yaml.tmpl", vars);
537
792
  }
538
793
  }
539
- const gitignorePath = join3(cwd, ".gitignore");
794
+ const gitignorePath = join7(cwd, ".gitignore");
540
795
  const gitignoreAdditions = [
541
796
  "",
542
797
  "# xera",
@@ -548,33 +803,55 @@ async function initCommand(opts) {
548
803
  "node_modules/"
549
804
  ].join(`
550
805
  `);
551
- if (existsSync3(gitignorePath)) {
552
- const current = readFileSync3(gitignorePath, "utf8");
806
+ if (existsSync7(gitignorePath)) {
807
+ const current = readFileSync4(gitignorePath, "utf8");
553
808
  if (!current.includes("# xera"))
554
809
  appendFileSync(gitignorePath, gitignoreAdditions);
555
810
  } else {
556
- writeFileSync2(gitignorePath, `${gitignoreAdditions.trim()}
811
+ writeFileSync5(gitignorePath, `${gitignoreAdditions.trim()}
557
812
  `);
558
813
  }
814
+ const editorTargets = await resolveEditors({
815
+ flag: opts.editor,
816
+ cwd,
817
+ isUpdate: false,
818
+ isYes: opts.yes,
819
+ prompt: async () => {
820
+ const choice = await p.multiselect({
821
+ message: "Which editor(s) should xera scaffold for?",
822
+ options: [
823
+ { value: "claude", label: "Claude Code (.claude/skills/, .claude/commands/)" },
824
+ { value: "cursor", label: "Cursor (.cursor/rules/, .cursor/commands/)" },
825
+ { value: "codex", label: "OpenAI Codex CLI (.agents/skills/)" }
826
+ ],
827
+ initialValues: ["claude"],
828
+ required: true
829
+ });
830
+ if (typeof choice === "symbol")
831
+ cancel2();
832
+ return choice;
833
+ }
834
+ });
559
835
  const skillsPkgPath = require2.resolve("@xera-ai/skills/package.json");
560
- const skillsSrcDir = join3(skillsPkgPath, "..");
836
+ const skillsSrcDir = join7(skillsPkgPath, "..");
561
837
  const SKILL_IGNORE = new Set(["package.json", "version.json", "CHANGELOG.md"]);
562
838
  for (const name of readdirSync2(skillsSrcDir)) {
563
839
  if (SKILL_IGNORE.has(name))
564
840
  continue;
565
841
  if (!name.endsWith(".md"))
566
842
  continue;
567
- const content = readFileSync3(join3(skillsSrcDir, name));
843
+ const raw = readFileSync4(join7(skillsSrcDir, name), "utf8");
844
+ const { frontmatter, body } = parseFrontmatter(raw);
568
845
  const base = name.replace(/\.md$/, "");
569
- const skillFile = join3(cwd, ".claude/skills", base, "SKILL.md");
570
- mkdirSync2(dirname2(skillFile), { recursive: true });
571
- writeFileSync2(skillFile, content);
572
- const cmdFile = join3(cwd, ".claude/commands", name);
573
- mkdirSync2(dirname2(cmdFile), { recursive: true });
574
- writeFileSync2(cmdFile, content);
846
+ const skillInput = { base, body, frontmatter };
847
+ for (const editorName of editorTargets) {
848
+ const adapter = editors[editorName];
849
+ adapter.scaffoldSkill(cwd, skillInput);
850
+ adapter.scaffoldCommand?.(cwd, skillInput);
851
+ }
575
852
  }
576
- const pkgPath = join3(cwd, "package.json");
577
- const pkg = existsSync3(pkgPath) ? JSON.parse(readFileSync3(pkgPath, "utf8")) : { name: "xera-project", private: true, type: "module" };
853
+ const pkgPath = join7(cwd, "package.json");
854
+ const pkg = existsSync7(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : { name: "xera-project", private: true, type: "module" };
578
855
  pkg.scripts = pkg.scripts ?? {};
579
856
  pkg.scripts["xera:fetch"] = "xera-internal fetch";
580
857
  pkg.scripts["xera:validate-feature"] = "xera-internal validate-feature";
@@ -610,7 +887,17 @@ async function initCommand(opts) {
610
887
  pkg.devDependencies["@playwright/test"] = "^1.60.0";
611
888
  pkg.devDependencies["@types/node"] = "^25.8.0";
612
889
  pkg.devDependencies["typescript"] = "^6.0.3";
613
- writeFileSync2(pkgPath, JSON.stringify(pkg, null, 2));
890
+ writeFileSync5(pkgPath, JSON.stringify(pkg, null, 2));
891
+ const editorLines = editorTargets.map((e) => {
892
+ if (e === "claude")
893
+ return " Claude Code: /xera-run <TICKET>";
894
+ if (e === "cursor")
895
+ return " Cursor: /xera-run <TICKET> (slash menu)";
896
+ if (e === "codex")
897
+ return ' OpenAI Codex CLI: type "run xera for <TICKET>" \u2014 Codex picks up the xera-run skill';
898
+ return "";
899
+ }).join(`
900
+ `);
614
901
  const nextSteps = shape === "api" ? `
615
902
  Next:
616
903
  1) Copy .env.example to .env and set your auth credentials:
@@ -619,7 +906,7 @@ Next:
619
906
  2) Run pre-authentication:
620
907
  bun run xera:auth-setup
621
908
  3) Start testing:
622
- Open Claude Code in this directory and run: /xera-run <TICKET>
909
+ ${editorLines}
623
910
  ` : shape === "mixed" ? `
624
911
  Next:
625
912
  1) Copy .env.example to .env and set credentials (both web logins and API tokens):
@@ -627,7 +914,7 @@ Next:
627
914
  2) Run pre-authentication:
628
915
  bun run xera:auth-setup
629
916
  3) Start testing:
630
- Open Claude Code in this directory and run: /xera-run <TICKET>
917
+ ${editorLines}
631
918
  ` : `
632
919
  Next:
633
920
  1) Copy .env.example to .env and set your Jira credentials:
@@ -635,7 +922,7 @@ Next:
635
922
  2) Run pre-authentication:
636
923
  bun run xera:auth-setup
637
924
  3) Start testing:
638
- Open Claude Code in this directory and run: /xera-run <TICKET>
925
+ ${editorLines}
639
926
  `;
640
927
  p.note(nextSteps.trim(), "Next steps");
641
928
  p.outro(pc2.green("xera initialized!"));
@@ -644,24 +931,23 @@ Next:
644
931
  // src/commands/init-update.ts
645
932
  import {
646
933
  copyFileSync,
647
- existsSync as existsSync4,
648
- mkdirSync as mkdirSync3,
934
+ existsSync as existsSync8,
935
+ mkdirSync as mkdirSync5,
649
936
  readdirSync as readdirSync3,
650
- readFileSync as readFileSync4,
651
- unlinkSync,
652
- writeFileSync as writeFileSync3
937
+ readFileSync as readFileSync5,
938
+ writeFileSync as writeFileSync6
653
939
  } from "fs";
654
940
  import { createRequire as createRequire2 } from "module";
655
- import { dirname as dirname3, join as join4 } from "path";
941
+ import { join as join8 } from "path";
656
942
  import * as p2 from "@clack/prompts";
657
943
  import pc3 from "picocolors";
658
944
  var require3 = createRequire2(import.meta.url);
659
945
  var CLI_VERSION2 = require3("../package.json").version;
660
946
  function detectAdaptersFromConfig(cwd) {
661
- const configPath = join4(cwd, "xera.config.ts");
662
- if (!existsSync4(configPath))
947
+ const configPath = join8(cwd, "xera.config.ts");
948
+ if (!existsSync8(configPath))
663
949
  return null;
664
- const cfg = readFileSync4(configPath, "utf8");
950
+ const cfg = readFileSync5(configPath, "utf8");
665
951
  const m = cfg.match(/adapters:\s*\[([^\]]+)\]/);
666
952
  if (!m)
667
953
  return null;
@@ -712,7 +998,7 @@ ${roles.map((r) => ` ${r}: { envEmail: 'TEST_${r.toUpperCase().replace(/-
712
998
  ` web: {`,
713
999
  ` baseUrl: { staging: '${baseUrl}' },`,
714
1000
  ` defaultEnv: 'staging',`,
715
- authBlock + ` },`
1001
+ `${authBlock} },`
716
1002
  ].join(`
717
1003
  `);
718
1004
  }
@@ -738,12 +1024,12 @@ export const web = defineAuthSetup(async (page, _role, creds) => {
738
1024
  async function initUpdateCommand(opts) {
739
1025
  const cwd = process.cwd();
740
1026
  p2.intro(pc3.cyan("xera init --update"));
741
- const pkgPath = join4(cwd, "package.json");
742
- if (!existsSync4(pkgPath)) {
1027
+ const pkgPath = join8(cwd, "package.json");
1028
+ if (!existsSync8(pkgPath)) {
743
1029
  p2.cancel("No package.json found \u2014 run `xera init` first.");
744
1030
  process.exit(1);
745
1031
  }
746
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1032
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
747
1033
  pkg.dependencies = pkg.dependencies ?? {};
748
1034
  pkg.dependencies["@xera-ai/core"] = `^${CLI_VERSION2}`;
749
1035
  pkg.dependencies["@xera-ai/prompts"] = `^${CLI_VERSION2}`;
@@ -763,79 +1049,47 @@ async function initUpdateCommand(opts) {
763
1049
  pkg.scripts["xera:impact-prepare"] = "xera-internal impact-prepare";
764
1050
  pkg.scripts["xera:heal-prepare"] = "xera-internal heal-prepare";
765
1051
  pkg.scripts["xera:disputes"] = "xera-internal disputes";
766
- writeFileSync3(pkgPath, JSON.stringify(pkg, null, 2));
767
- const wfDir = join4(cwd, ".github/workflows");
768
- mkdirSync3(wfDir, { recursive: true });
1052
+ writeFileSync6(pkgPath, JSON.stringify(pkg, null, 2));
1053
+ const wfDir = join8(cwd, ".github/workflows");
1054
+ mkdirSync5(wfDir, { recursive: true });
769
1055
  try {
770
1056
  const cliPkgPath = require3.resolve("@xera-ai/cli/package.json");
771
- const cliTplPath = join4(cliPkgPath, "..", "templates/xera-graph.yml.template");
772
- copyFileSync(cliTplPath, join4(wfDir, "xera-graph.yml"));
1057
+ const cliTplPath = join8(cliPkgPath, "..", "templates/xera-graph.yml.template");
1058
+ copyFileSync(cliTplPath, join8(wfDir, "xera-graph.yml"));
773
1059
  p2.log.info("scaffolded .github/workflows/xera-graph.yml");
774
1060
  } catch (_e) {
775
1061
  p2.log.warn("skipped xera-graph.yml scaffold (re-run `xera init` to create it)");
776
1062
  }
777
- const skillsSrc = require3.resolve("@xera-ai/skills/package.json");
778
- const newSkillsDir = join4(skillsSrc, "..");
779
- const SKILL_IGNORE = new Set(["package.json", "version.json", "CHANGELOG.md"]);
780
- for (const name of readdirSync3(newSkillsDir)) {
781
- if (SKILL_IGNORE.has(name))
782
- continue;
783
- if (!name.endsWith(".md"))
784
- continue;
785
- const newContent = readFileSync4(join4(newSkillsDir, name), "utf8");
786
- const base = name.replace(/\.md$/, "");
787
- const skillPath = join4(cwd, ".claude/skills", base, "SKILL.md");
788
- const legacyFlatSkillPath = join4(cwd, ".claude/skills", name);
789
- const cmdPath = join4(cwd, ".claude/commands", name);
790
- let migratedLegacy = false;
791
- if (existsSync4(legacyFlatSkillPath) && !existsSync4(skillPath)) {
792
- const legacyContent = readFileSync4(legacyFlatSkillPath, "utf8");
793
- mkdirSync3(dirname3(skillPath), { recursive: true });
794
- writeFileSync3(skillPath, legacyContent);
795
- unlinkSync(legacyFlatSkillPath);
796
- migratedLegacy = true;
797
- }
798
- const targets = [];
799
- for (const path of [skillPath, cmdPath]) {
800
- if (!existsSync4(path)) {
801
- targets.push({ path, state: "missing" });
802
- } else {
803
- const content = readFileSync4(path, "utf8");
804
- targets.push({ path, state: content === newContent ? "same" : "diff" });
805
- }
806
- }
807
- if (targets.every((s) => s.state === "missing")) {
808
- for (const { path } of targets) {
809
- mkdirSync3(dirname3(path), { recursive: true });
810
- writeFileSync3(path, newContent);
811
- }
812
- p2.log.info(`+ ${name}`);
813
- continue;
814
- }
815
- if (targets.every((s) => s.state === "same")) {
816
- if (migratedLegacy)
817
- p2.log.success(`migrated ${name} to .claude/skills/${base}/SKILL.md`);
818
- else
819
- p2.log.info(`= ${name}`);
820
- continue;
821
- }
822
- const choice = await p2.select({
823
- message: `${name} differs from package version`,
824
- options: [
825
- { value: "keep", label: "Keep local" },
826
- { value: "overwrite", label: "Overwrite with package version" }
827
- ]
828
- });
829
- if (choice === "overwrite") {
830
- for (const { path } of targets) {
831
- mkdirSync3(dirname3(path), { recursive: true });
832
- writeFileSync3(path, newContent);
1063
+ const editorTargets = await resolveEditors({
1064
+ flag: opts.editor,
1065
+ cwd,
1066
+ isUpdate: true,
1067
+ isYes: opts.yes
1068
+ });
1069
+ if (editorTargets.length === 0) {
1070
+ p2.log.warn("No editor integration detected in this project. Pass --editor claude|cursor|codex|all to add one.");
1071
+ } else {
1072
+ const skillsSrc = require3.resolve("@xera-ai/skills/package.json");
1073
+ const newSkillsDir = join8(skillsSrc, "..");
1074
+ const SKILL_IGNORE = new Set(["package.json", "version.json", "CHANGELOG.md"]);
1075
+ for (const name of readdirSync3(newSkillsDir)) {
1076
+ if (SKILL_IGNORE.has(name))
1077
+ continue;
1078
+ if (!name.endsWith(".md"))
1079
+ continue;
1080
+ const rawNew = readFileSync5(join8(newSkillsDir, name), "utf8");
1081
+ const { frontmatter, body } = parseFrontmatter(rawNew);
1082
+ const base = name.replace(/\.md$/, "");
1083
+ const skillInput = { base, body, frontmatter };
1084
+ for (const editorName of editorTargets) {
1085
+ const adapter = editors[editorName];
1086
+ const migrated = adapter.legacyMigrate?.(cwd, base) ?? false;
1087
+ if (migrated)
1088
+ p2.log.success(`migrated ${base} (${editorName}) to new layout`);
1089
+ adapter.scaffoldSkill(cwd, skillInput);
1090
+ adapter.scaffoldCommand?.(cwd, skillInput);
833
1091
  }
834
- p2.log.success(`overwrote ${name}`);
835
- } else {
836
- if (migratedLegacy)
837
- p2.log.success(`migrated ${name} to .claude/skills/${base}/SKILL.md`);
838
- p2.log.warn(`kept local ${name}`);
1092
+ p2.log.info(`refreshed ${base} across [${editorTargets.join(", ")}]`);
839
1093
  }
840
1094
  }
841
1095
  const hasShapeFlags = opts.apiBaseUrl !== undefined || opts.openapiPath !== undefined || opts.authStrategy !== undefined || opts.httpRoles !== undefined || opts.stagingUrl !== undefined || opts.authEnabled !== undefined || opts.roles !== undefined;
@@ -933,7 +1187,7 @@ async function main() {
933
1187
  cli.help();
934
1188
  cli.version(VERSION);
935
1189
  cli.usage("<command> [options]");
936
- cli.command("init", "Scaffold a new xera project in the current directory").option("--update", "Non-destructive refresh of an existing project").option("-y, --yes", "Accept all defaults (non-interactive)").option("--shape <shape>", "Project shape: web | api | mixed").option("--ju, --jira-base-url <url>", "Jira workspace URL").option("--pk, --project-keys <keys>", "Jira project key(s), comma-separated").option("--sf, --story-field <field>", "Jira field id for user story (default: description)").option("--ac, --ac-field <field>", "Jira field id for acceptance criteria").option("--su, --staging-url <url>", "Web app staging URL").option("--auth-enabled", "App requires login to test most pages").option("--no-auth-enabled", "App does not require login").option("--ro, --roles <roles>", "Test user roles, comma-separated (default: admin,regular)").option("--au, --api-base-url <url>", "API base URL").option("--op, --openapi-path <path>", "OpenAPI spec path or URL").option("--as, --auth-strategy <strategy>", `API auth strategy: ${VALID_AUTH_STRATEGIES.join(" | ")}`).option("--hr, --http-roles <roles>", "HTTP test roles, comma-separated (default: user)").example("xera init").example("xera init -y --shape web").example("xera init -y --shape api --pk MYPROJ --ju https://myco.atlassian.net --au https://api.staging.example.com --as bearer").example("xera init -y --shape mixed --pk PROJ --ju https://myco.atlassian.net --su https://staging.example.com --au https://api.staging.example.com").action(async (opts) => {
1190
+ cli.command("init", "Scaffold a new xera project in the current directory").option("--update", "Non-destructive refresh of an existing project").option("-y, --yes", "Accept all defaults (non-interactive)").option("--shape <shape>", "Project shape: web | api | mixed").option("--editor <list>", 'Editor(s) to scaffold: claude,cursor,codex or "all" (default: auto-detect or all)').option("--ju, --jira-base-url <url>", "Jira workspace URL").option("--pk, --project-keys <keys>", "Jira project key(s), comma-separated").option("--sf, --story-field <field>", "Jira field id for user story (default: description)").option("--ac, --ac-field <field>", "Jira field id for acceptance criteria").option("--su, --staging-url <url>", "Web app staging URL").option("--auth-enabled", "App requires login to test most pages").option("--no-auth-enabled", "App does not require login").option("--ro, --roles <roles>", "Test user roles, comma-separated (default: admin,regular)").option("--au, --api-base-url <url>", "API base URL").option("--op, --openapi-path <path>", "OpenAPI spec path or URL").option("--as, --auth-strategy <strategy>", `API auth strategy: ${VALID_AUTH_STRATEGIES.join(" | ")}`).option("--hr, --http-roles <roles>", "HTTP test roles, comma-separated (default: user)").example("xera init").example("xera init -y --shape web").example("xera init -y --shape web --editor claude,cursor").example("xera init -y --shape api --pk MYPROJ --ju https://myco.atlassian.net --au https://api.staging.example.com --as bearer").example("xera init -y --shape mixed --pk PROJ --ju https://myco.atlassian.net --su https://staging.example.com --au https://api.staging.example.com").action(async (opts) => {
937
1191
  if (opts.update) {
938
1192
  const updateOpts = { yes: !!opts.yes };
939
1193
  if (opts.shape !== undefined) {
@@ -966,6 +1220,8 @@ async function main() {
966
1220
  updateOpts.authEnabled = opts.authEnabled;
967
1221
  if (opts.roles !== undefined)
968
1222
  updateOpts.roles = opts.roles;
1223
+ if (opts.editor !== undefined)
1224
+ updateOpts.editor = opts.editor;
969
1225
  await initUpdateCommand(updateOpts);
970
1226
  return;
971
1227
  }
@@ -1008,6 +1264,8 @@ async function main() {
1008
1264
  initOpts.openapiPath = opts.openapiPath;
1009
1265
  if (opts.httpRoles !== undefined)
1010
1266
  initOpts.httpRoles = opts.httpRoles;
1267
+ if (opts.editor !== undefined)
1268
+ initOpts.editor = opts.editor;
1011
1269
  await initCommand(initOpts);
1012
1270
  });
1013
1271
  cli.command("doctor", "Run a health check").option("--strict <ticket>", "Treat ticket-specific checks as required").option("--logs <ticket>", "Pretty-print xera.log for a ticket").option("--usage", "Show token/usage summary from recent runs").action(async (opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/cli",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "xera": "./bin/xera"
@@ -15,8 +15,8 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@xera-ai/core": "^0.13.0",
19
- "@xera-ai/skills": "^0.13.0",
18
+ "@xera-ai/core": "^0.13.1",
19
+ "@xera-ai/skills": "^0.13.1",
20
20
  "@clack/prompts": "1.4.0",
21
21
  "cac": "7.0.0",
22
22
  "picocolors": "1.1.1",