@lizard-build/cli 4.0.44 → 4.0.46

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 (68) hide show
  1. package/AGENTS.md +3 -0
  2. package/dist/commands/add.js +2 -7
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/agents.js +21 -2
  5. package/dist/commands/agents.js.map +1 -1
  6. package/dist/commands/domain.js +32 -2
  7. package/dist/commands/domain.js.map +1 -1
  8. package/dist/commands/init.js +15 -0
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/commands/metrics.js +11 -2
  11. package/dist/commands/metrics.js.map +1 -1
  12. package/dist/commands/s3.d.ts +15 -0
  13. package/dist/commands/s3.js +59 -0
  14. package/dist/commands/s3.js.map +1 -1
  15. package/dist/commands/sandbox.js +13 -5
  16. package/dist/commands/sandbox.js.map +1 -1
  17. package/dist/commands/skill.js +89 -16
  18. package/dist/commands/skill.js.map +1 -1
  19. package/dist/commands/up.js +23 -6
  20. package/dist/commands/up.js.map +1 -1
  21. package/dist/commands/upgrade.js +17 -3
  22. package/dist/commands/upgrade.js.map +1 -1
  23. package/dist/commands/volume.js +38 -4
  24. package/dist/commands/volume.js.map +1 -1
  25. package/dist/commands/workspace.js +4 -7
  26. package/dist/commands/workspace.js.map +1 -1
  27. package/dist/index.js +2 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/lib/api.js +21 -6
  30. package/dist/lib/api.js.map +1 -1
  31. package/dist/lib/config.d.ts +7 -0
  32. package/dist/lib/config.js +10 -1
  33. package/dist/lib/config.js.map +1 -1
  34. package/dist/lib/default-project.js +7 -2
  35. package/dist/lib/default-project.js.map +1 -1
  36. package/dist/lib/skills-data.generated.d.ts +2 -0
  37. package/dist/lib/skills-data.generated.js +6 -1
  38. package/dist/lib/skills-data.generated.js.map +1 -1
  39. package/dist/lib/updater.d.ts +1 -1
  40. package/dist/lib/updater.js +1 -1
  41. package/package.json +1 -1
  42. package/scripts/gen-skills.mjs +24 -5
  43. package/skill-data/core/SKILL.md +122 -377
  44. package/skill-data/core/references/deploys.md +201 -0
  45. package/skill-data/core/references/sandboxes.md +169 -0
  46. package/skill-data/core/references/sdk.md +227 -0
  47. package/src/commands/add.ts +2 -6
  48. package/src/commands/agents.ts +20 -4
  49. package/src/commands/domain.ts +36 -2
  50. package/src/commands/init.ts +18 -0
  51. package/src/commands/metrics.ts +11 -2
  52. package/src/commands/s3.ts +65 -0
  53. package/src/commands/sandbox.ts +15 -5
  54. package/src/commands/skill.ts +95 -21
  55. package/src/commands/up.ts +20 -6
  56. package/src/commands/upgrade.ts +14 -3
  57. package/src/commands/volume.ts +35 -4
  58. package/src/commands/workspace.ts +4 -9
  59. package/src/index.ts +4 -1
  60. package/src/lib/api.ts +21 -6
  61. package/src/lib/config.ts +11 -1
  62. package/src/lib/default-project.ts +5 -2
  63. package/src/lib/updater.ts +1 -1
  64. package/test/unit/default-project.test.ts +8 -2
  65. package/test/unit/s3-presign.test.ts +32 -0
  66. package/test/unit/sandbox-transport.test.ts +5 -1
  67. package/test/unit/sandbox.test.ts +1 -0
  68. package/test/unit/skills.test.ts +31 -12
@@ -273,7 +273,6 @@ async function runAdd(input: AddInput): Promise<void> {
273
273
  if (opts.variables?.length) {
274
274
  info(chalk.yellow("Warning: --variables is ignored for managed addons"));
275
275
  }
276
- const isSingle = databases.length === 1;
277
276
  for (const db of databases) {
278
277
  const cat = CATALOG.find((c) => c.name === db)!;
279
278
  info(`Adding ${chalk.cyan(cat.label)}...`);
@@ -303,11 +302,8 @@ async function runAdd(input: AddInput): Promise<void> {
303
302
  }
304
303
  }
305
304
 
306
- if (isSingle) {
307
- try {
308
- updateProjectLink({ serviceId: addon.id, serviceName: addon.name });
309
- } catch {}
310
- }
305
+ // The folder keeps its service link: linking it to the addon sent the next
306
+ // `lizard up` / `secrets set` / `logs --build` to a database ("App not found").
311
307
  }
312
308
  return;
313
309
  }
@@ -3,7 +3,7 @@ import ora from "ora";
3
3
  import open from "open";
4
4
  import { Command } from "commander";
5
5
  import * as p from "@clack/prompts";
6
- import { api } from "../lib/api.js";
6
+ import { api, APIError } from "../lib/api.js";
7
7
  import { success, info, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
8
8
 
9
9
  // Built-in coding agents (platform: server/src/routes/agents.ts), Boat-style.
@@ -185,6 +185,24 @@ function render(e: AgentEvent, verbose: boolean) {
185
185
  }
186
186
  }
187
187
 
188
+ /**
189
+ * One page of a conversation's events. The agent keeps working in the sandbox while we
190
+ * watch, so a dropped connection or a 5xx must not end the watch: retry for about two
191
+ * minutes before giving up.
192
+ */
193
+ async function pollEvents(id: string, conversationId: string, after: number) {
194
+ const path = `/api/sandboxes/${id}/events?after=${after}&conversationId=${conversationId}&limit=500`;
195
+ for (let attempt = 1; ; attempt++) {
196
+ try {
197
+ return await api.get<{ events: AgentEvent[]; next: number }>(path);
198
+ } catch (e) {
199
+ const transient = !(e instanceof APIError) || e.status === 429 || e.status >= 500;
200
+ if (!transient || attempt >= 24) throw e;
201
+ await sleep(Math.min(1000 * attempt, 5000));
202
+ }
203
+ }
204
+ }
205
+
188
206
  export function registerSandboxAgentCommands(sb: Command) {
189
207
  sb.command("prompt")
190
208
  .argument("<id>", "Sandbox ID")
@@ -223,9 +241,7 @@ Examples:
223
241
  });
224
242
  let after = 0;
225
243
  for (;;) {
226
- const r = await api.get<{ events: AgentEvent[]; next: number }>(
227
- `/api/sandboxes/${id}/events?after=${after}&conversationId=${started.conversationId}&limit=500`,
228
- );
244
+ const r = await pollEvents(id, started.conversationId, after);
229
245
  after = r.next ?? after;
230
246
  for (const e of r.events) {
231
247
  if (e.promptId !== started.promptId) continue;
@@ -4,7 +4,7 @@ import { Command } from "commander";
4
4
  import { api } from "../lib/api.js";
5
5
  import { resolveProjectId } from "../lib/config.js";
6
6
  import { getActiveService } from "../lib/resolve.js";
7
- import { success, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
7
+ import { success, isJSONMode, printJSON, table, isTTY, info } from "../lib/format.js";
8
8
 
9
9
  interface DomainResponse {
10
10
  ok?: boolean;
@@ -92,7 +92,14 @@ export function registerDomain(program: Command) {
92
92
  return;
93
93
  }
94
94
 
95
- // Attach custom hostname
95
+ // Attach custom hostname. Anything without a dot is a typo or a missing subcommand
96
+ // (`lizard domain list` once attached a domain literally named "list").
97
+ if (!/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/i.test(hostname)) {
98
+ throw new Error(
99
+ `"${hostname}" is not a domain name. Attach a full hostname such as app.example.com; ` +
100
+ "`lizard domain list` shows the service's domains.",
101
+ );
102
+ }
96
103
  const result = await api
97
104
  .post<DomainResponse>(`/api/apps/${service.id}/domains`, {
98
105
  hostname,
@@ -250,6 +257,33 @@ export function registerDomain(program: Command) {
250
257
  }
251
258
  });
252
259
 
260
+ dom
261
+ .command("list")
262
+ .alias("ls")
263
+ .description("List the service's domains, and custom domains still waiting for DNS verification")
264
+ .action(async (_opts, sub) => {
265
+ const opts = sub.optsWithGlobals();
266
+ const projectId = await resolveProjectId(opts.project);
267
+ const service = await getActiveService(opts.service, projectId);
268
+ const r = await api.get<{
269
+ domains?: string[]; generatedDomain?: string | null; cnameTarget?: string;
270
+ pending?: { hostname: string; txtRecord: string; txtValue: string; cnameTarget: string }[];
271
+ }>(`/api/apps/${service.id}/domains`);
272
+ if (isJSONMode()) return printJSON({ service: service.name, ...r });
273
+ const active = r.domains ?? [];
274
+ if (!active.length && !(r.pending ?? []).length) {
275
+ info(`${service.name} has no domain yet. Generate one: lizard domain --service ${service.name}`);
276
+ return;
277
+ }
278
+ for (const d of active) console.log(`${chalk.green("●")} https://${d}`);
279
+ for (const p of r.pending ?? []) {
280
+ console.log(`${chalk.yellow("○")} ${p.hostname} ${chalk.dim("(waiting for DNS)")}`);
281
+ console.log(chalk.dim(` TXT ${p.txtRecord} ${p.txtValue}`));
282
+ console.log(chalk.dim(` CNAME ${p.hostname} ${p.cnameTarget}`));
283
+ console.log(chalk.dim(` then: lizard domain verify ${p.hostname} --service ${service.name}`));
284
+ }
285
+ });
286
+
253
287
  dom
254
288
  .command("verify")
255
289
  .argument("<hostname>", "Custom domain to verify")
@@ -83,6 +83,24 @@ export async function ensureLinked(opts: {
83
83
  }
84
84
  }
85
85
 
86
+ // A named project may live in any workspace. Look it up everywhere first: the flow
87
+ // below searches one workspace only and creates the project when that workspace has
88
+ // no match, which made a duplicate of a project in another workspace (and, for a key
89
+ // limited to that project, failed with 404 "Project not found").
90
+ if (opts.projectName && !opts.workspaceFlag) {
91
+ const lower = opts.projectName.toLowerCase();
92
+ const all = await api.get<Array<Project & { workspaceId?: string; workspaceName?: string }>>("/api/projects");
93
+ const matches = all.filter(
94
+ (pr) => pr.id.toLowerCase() === lower || pr.slug?.toLowerCase() === lower || pr.name.toLowerCase() === lower,
95
+ );
96
+ if (matches.length === 1) {
97
+ const m = matches[0];
98
+ const link: ProjectLink = { projectId: m.id, projectName: m.name, workspaceId: m.workspaceId, workspaceName: m.workspaceName };
99
+ setProjectLink(link);
100
+ return link;
101
+ }
102
+ }
103
+
86
104
  // 1. Workspace
87
105
  const workspaces = await fetchWorkspaces();
88
106
  const workspace = await pickWorkspace({
@@ -2,7 +2,7 @@ import chalk from "chalk";
2
2
  import { Command } from "commander";
3
3
  import { api, withScope, withQuery, APIError, type ResourceScope } from "../lib/api.js";
4
4
  import { getProjectLink } from "../lib/config.js";
5
- import { resolveProjectScope, getActiveServiceWithKind } from "../lib/resolve.js";
5
+ import { resolveProjectScope, getActiveServiceWithKind, lookupProjectWorkspace } from "../lib/resolve.js";
6
6
  import { info, fail, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
7
7
 
8
8
  // Shapes returned by /api/apps/:id/metrics and /api/projects/:id/metrics.
@@ -584,6 +584,11 @@ function fmtPeriodDate(ms: number): string {
584
584
  }
585
585
 
586
586
  async function showCost(projectId: string, scope: ResourceScope) {
587
+ // Scopes for other commands skip the workspace lookup (their routes ignore it), so an
588
+ // unlinked folder or --project arrives here without one: fetch it, cost needs it.
589
+ if (!scope.workspaceId) {
590
+ scope = { ...scope, workspaceId: (await lookupProjectWorkspace(projectId))?.workspaceId ?? null };
591
+ }
587
592
  if (!scope.workspaceId) {
588
593
  fail("Could not resolve the workspace for this project. Run `lizard link` first.");
589
594
  }
@@ -599,7 +604,11 @@ async function showCost(projectId: string, scope: ResourceScope) {
599
604
  ]);
600
605
  } catch (e) {
601
606
  if (e instanceof APIError && e.status === 403) {
602
- fail("Billing is only visible to the workspace owner.", 2);
607
+ // A key limited to some projects can't read the workspace's billing at all; only
608
+ // a plain 403 means "not the owner".
609
+ fail(/API key/i.test(e.message)
610
+ ? `${e.message} Costs need a key that covers the whole workspace, or a full sign-in (lizard login).`
611
+ : "Billing is only visible to the workspace owner.", 2);
603
612
  }
604
613
  throw e;
605
614
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { createHash, createHmac } from "node:crypto";
3
4
  import chalk from "chalk";
4
5
  import { Command } from "commander";
5
6
  import { api, getBaseURL, withScope, APIError, type ResourceScope } from "../lib/api.js";
@@ -12,6 +13,41 @@ interface AddonLite {
12
13
  name: string;
13
14
  type?: string;
14
15
  status?: string;
16
+ connection?: { endpoint?: string; accessKeyId?: string; secretKey?: string; region?: string } | null;
17
+ }
18
+
19
+ const rfc3986 = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
20
+ const sha256hex = (v: string) => createHash("sha256").update(v).digest("hex");
21
+ const hmac = (key: Buffer | string, v: string) => createHmac("sha256", key).update(v).digest();
22
+
23
+ /**
24
+ * A SigV4 presigned GET for one object (path style, the way the S3 gateway expects it).
25
+ * The link works for `expiresIn` seconds without any credentials, so it is safe to hand
26
+ * to someone who must not get the addon's keys.
27
+ */
28
+ export function presignGet(o: {
29
+ endpoint: string; accessKeyId: string; secretKey: string; region: string;
30
+ bucket: string; key: string; expiresIn: number; now?: Date;
31
+ }): string {
32
+ const base = new URL(o.endpoint);
33
+ const segments = [...base.pathname.split("/").filter(Boolean), o.bucket, ...o.key.split("/")];
34
+ const canonicalUri = "/" + segments.map(rfc3986).join("/");
35
+ const iso = (o.now ?? new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
36
+ const date = iso.slice(0, 8);
37
+ const scope = `${date}/${o.region}/s3/aws4_request`;
38
+ const query: Record<string, string> = {
39
+ "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
40
+ "X-Amz-Credential": `${o.accessKeyId}/${scope}`,
41
+ "X-Amz-Date": iso,
42
+ "X-Amz-Expires": String(o.expiresIn),
43
+ "X-Amz-SignedHeaders": "host",
44
+ };
45
+ const canonicalQuery = Object.keys(query).sort().map((k) => `${rfc3986(k)}=${rfc3986(query[k])}`).join("&");
46
+ const canonicalRequest = ["GET", canonicalUri, canonicalQuery, `host:${base.host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
47
+ const toSign = ["AWS4-HMAC-SHA256", iso, scope, sha256hex(canonicalRequest)].join("\n");
48
+ const key = hmac(hmac(hmac(hmac("AWS4" + o.secretKey, date), o.region), "s3"), "aws4_request");
49
+ const signature = createHmac("sha256", key).update(toSign).digest("hex");
50
+ return `${base.protocol}//${base.host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
15
51
  }
16
52
 
17
53
  interface ServicesResponse {
@@ -177,6 +213,35 @@ export function registerS3(program: Command) {
177
213
  }
178
214
  });
179
215
 
216
+ s3.command("url")
217
+ .description("Print a temporary download link for an object (works without credentials until it expires)")
218
+ .argument("<key>", "Object key, e.g. report.txt")
219
+ .option("--addon <name>", "S3 addon name or ID (default: the project's only S3 addon)")
220
+ .option("--bucket <name>", "Bucket", "default")
221
+ .option("--expires <seconds>", "How long the link works, in seconds (max 604800 = 7 days)", "3600")
222
+ .option("-p, --project <id>", "Project name, slug, or ID")
223
+ .action(async (key: string, opts) => {
224
+ const expiresIn = Number(opts.expires);
225
+ if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 604800) {
226
+ throw new Error(`--expires must be a whole number of seconds from 1 to 604800 (got ${opts.expires}).`);
227
+ }
228
+ const { projectId, scope } = await resolveProjectScope(opts.project);
229
+ const addon = await resolveS3AddonId(projectId, scope, opts.addon);
230
+ const data = await api.get<ServicesResponse>(withScope(`/api/projects/${projectId}/services`, scope));
231
+ const c = (data.addons ?? []).find((a) => a.id === addon.id)?.connection;
232
+ if (!c?.endpoint || !c.accessKeyId || !c.secretKey) {
233
+ throw new Error(`S3 addon ${addon.name} has no credentials yet; wait until it is running (lizard ps).`);
234
+ }
235
+ const url = presignGet({
236
+ endpoint: c.endpoint, accessKeyId: c.accessKeyId, secretKey: c.secretKey, region: c.region || "us-east-1",
237
+ bucket: opts.bucket, key, expiresIn,
238
+ });
239
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
240
+ if (isJSONMode()) return printJSON({ url, key, bucket: opts.bucket, expiresIn, expiresAt });
241
+ console.log(url);
242
+ info(chalk.dim(`Works until ${expiresAt}. Anyone with the link can download ${key}.`));
243
+ });
244
+
180
245
  s3.command("list")
181
246
  .alias("ls")
182
247
  .description("List objects in an S3 addon bucket")
@@ -9,7 +9,7 @@ import open from "open";
9
9
  import { api, APIError, getBaseURL, getRawBytes, apiErrorFrom, clientHeaders, getRequestToken, withQuery, withScope, type ResourceScope } from "../lib/api.js";
10
10
  import { getToken } from "../lib/auth.js";
11
11
  import { resolveProjectScope } from "../lib/resolve.js";
12
- import { getProjectLink, resolveProjectId } from "../lib/config.js";
12
+ import { getProjectLink, resolveProjectId, looksLikeId } from "../lib/config.js";
13
13
  import { resolveVolume } from "../lib/volume.js";
14
14
  import { sandboxShell } from "./sandbox-ssh.js";
15
15
  import { startVncTunnel, vncTarget } from "./sandbox-vnc.js";
@@ -21,7 +21,7 @@ import { success, info, error, isJSONMode, printJSON, table, statusColor, timeAg
21
21
  // still rejected before a request was ever sent — codex and interpreter both were. The
22
22
  // server validates against the region's actual list and answers with what IS available,
23
23
  // so let it.
24
- const TEMPLATE_HINT = "base, codex, interpreter, desktop";
24
+ const TEMPLATE_HINT = "base, interpreter, codex, claude, opencode, pi, prime, desktop";
25
25
 
26
26
  // The three machines a sandbox can be, each priced per hour, billed per second by the server. The
27
27
  // platform rejects anything else, so the CLI offers exactly these.
@@ -130,7 +130,7 @@ export function registerSandbox(program: Command) {
130
130
  .option("--region <code>", "Region to create the sandbox in")
131
131
  .option("--snapshot <id>", "Create from a private saved snapshot (runs on the machine it was captured on)")
132
132
  .option("--volume <name-or-id>", "Attach a persistent volume")
133
- .option("--with-token [key]", "Install this liz_ key inside the sandbox so `lizard` works there (defaults to the key you are using)")
133
+ .option("--with-token [key]", "Sign in `lizard` inside the sandbox with a new key limited to the sandbox's project and deleted with it (this liz_ key, or the one you use, only authorizes the request)")
134
134
  .option("-p, --project <id>", "Project to create the sandbox in (name, slug, or ID). Defaults to the linked project.")
135
135
  .action(async (opts) => {
136
136
  // A snapshot restores onto the machine it was captured on, so the server ignores a
@@ -170,7 +170,7 @@ export function registerSandbox(program: Command) {
170
170
  let volumeId: string | undefined;
171
171
  let volumeName: string | undefined;
172
172
  if (opts.volume) {
173
- if (/^[A-Za-z0-9_-]{21}$/.test(opts.volume)) volumeId = opts.volume;
173
+ if (looksLikeId(opts.volume)) volumeId = opts.volume;
174
174
  else volumeName = opts.volume;
175
175
  }
176
176
 
@@ -543,7 +543,7 @@ Examples:
543
543
  api.get<SnapshotRecord>(`/api/sandbox-snapshots/${snapshotId}`),
544
544
  opts.project ? resolveProjectId(opts.project) : Promise.resolve(undefined),
545
545
  ]);
546
- const volume = opts.volume ? (/^fcv-|^[A-Za-z0-9_-]{21}$/.test(opts.volume) ? { volumeId: opts.volume } : { volumeName: opts.volume }) : {};
546
+ const volume = opts.volume ? ((/^fcv-/.test(opts.volume) || looksLikeId(opts.volume)) ? { volumeId: opts.volume } : { volumeName: opts.volume }) : {};
547
547
  // A volume picks its own project when none is named; a name needs one to be found in.
548
548
  const project = projectId ?? (volume.volumeId ? undefined : snapshot.projectId);
549
549
  const sandbox = await api.post<SandboxRecord>("/api/sandboxes", { snapshotId, projectId: project, region: snapshot.region, timeoutMs: opts.timeout, ...volume });
@@ -748,6 +748,16 @@ export function execStream(
748
748
  if (currentEvent === "exit") {
749
749
  try {
750
750
  const code: unknown = JSON.parse(data).exitCode;
751
+ // -1: the platform stopped waiting (150 s). Say so; the bare "invalid event"
752
+ // left agents guessing and retrying the same long command.
753
+ if (code === -1) {
754
+ reject(new APIError(408,
755
+ "The command was still running after 150 seconds, so exec stopped waiting (it may still be running in the sandbox). " +
756
+ `Start long jobs in the background and read a log: lizard sandbox exec ${sandboxId} -- sh -c 'nohup <cmd> > /tmp/job.log 2>&1 &', ` +
757
+ `then lizard sandbox exec ${sandboxId} -- tail -n 50 /tmp/job.log`,
758
+ "EXEC_TIMEOUT"));
759
+ return;
760
+ }
751
761
  if (!Number.isInteger(code) || (code as number) < 0 || (code as number) > 255) {
752
762
  throw new Error("Invalid exec exit code");
753
763
  }
@@ -11,11 +11,16 @@ import { EMBEDDED_SKILLS } from "../lib/skills-data.generated.js";
11
11
  //
12
12
  // LIZARD_SKILLS_DIR overrides the embedded source with a real directory — useful
13
13
  // for authoring new skills locally without a rebuild.
14
+ //
15
+ // A skill is SKILL.md plus optional references/<ref>.md files that go deeper on
16
+ // one part of the platform; `lizard skills get <name> --ref <ref>` prints one.
14
17
 
15
18
  interface Source {
16
19
  list(): string[];
17
20
  read(name: string): string | null;
18
- pathOf(name?: string): string;
21
+ refs(name: string): string[];
22
+ readRef(name: string, ref: string): string | null;
23
+ pathOf(name?: string, ref?: string): string;
19
24
  }
20
25
 
21
26
  function fsSource(dir: string): Source {
@@ -42,7 +47,29 @@ function fsSource(dir: string): Source {
42
47
  return null;
43
48
  }
44
49
  },
45
- pathOf: (name) => (name ? path.join(dir, name, "SKILL.md") : dir),
50
+ refs: (name) => {
51
+ try {
52
+ return readdirSync(path.join(dir, name, "references"))
53
+ .filter((f) => f.endsWith(".md"))
54
+ .map((f) => f.slice(0, -3))
55
+ .sort();
56
+ } catch {
57
+ return [];
58
+ }
59
+ },
60
+ readRef: (name, ref) => {
61
+ try {
62
+ return readFileSync(path.join(dir, name, "references", `${ref}.md`), "utf8");
63
+ } catch {
64
+ return null;
65
+ }
66
+ },
67
+ pathOf: (name, ref) => {
68
+ if (!name) return dir;
69
+ return ref
70
+ ? path.join(dir, name, "references", `${ref}.md`)
71
+ : path.join(dir, name, "SKILL.md");
72
+ },
46
73
  };
47
74
  }
48
75
 
@@ -50,8 +77,14 @@ function embeddedSource(): Source {
50
77
  return {
51
78
  list: () => Object.keys(EMBEDDED_SKILLS).sort(),
52
79
  read: (name) => EMBEDDED_SKILLS[name]?.content ?? null,
53
- pathOf: (name) =>
54
- name ? `embedded://skills/${name}/SKILL.md` : "embedded://skills",
80
+ refs: (name) => Object.keys(EMBEDDED_SKILLS[name]?.references ?? {}).sort(),
81
+ readRef: (name, ref) => EMBEDDED_SKILLS[name]?.references[ref] ?? null,
82
+ pathOf: (name, ref) => {
83
+ if (!name) return "embedded://skills";
84
+ return ref
85
+ ? `embedded://skills/${name}/references/${ref}.md`
86
+ : `embedded://skills/${name}/SKILL.md`;
87
+ },
55
88
  };
56
89
  }
57
90
 
@@ -97,6 +130,29 @@ function readSkill(
97
130
  return { md, meta: parseFrontmatter(md) };
98
131
  }
99
132
 
133
+ function readRef(src: Source, name: string, ref: string): string {
134
+ readSkill(src, name);
135
+ const md = src.readRef(name, ref);
136
+ if (md == null) {
137
+ const refs = src.refs(name);
138
+ throw new Error(
139
+ `Reference '${ref}' not found in skill '${name}'. ` +
140
+ (refs.length
141
+ ? `Available: ${refs.join(", ")}.`
142
+ : `This skill has no references.`),
143
+ );
144
+ }
145
+ return md;
146
+ }
147
+
148
+ function refEntries(src: Source, name: string) {
149
+ return src.refs(name).map((ref) => ({
150
+ name: ref,
151
+ path: src.pathOf(name, ref),
152
+ command: `lizard skills get ${name} --ref ${ref}`,
153
+ }));
154
+ }
155
+
100
156
  function shortDesc(desc: string, max = 100): string {
101
157
  const oneLine = desc.replace(/\s+/g, " ").trim();
102
158
  return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
@@ -118,12 +174,13 @@ export function registerSkill(program: Command) {
118
174
  printJSON({
119
175
  dir: src.pathOf(),
120
176
  skills: names.map((n) => {
177
+ let description = "";
121
178
  try {
122
- const { meta } = readSkill(src, n);
123
- return { name: n, description: meta.description || "" };
179
+ description = readSkill(src, n).meta.description || "";
124
180
  } catch {
125
- return { name: n, description: "" };
181
+ // unreadable SKILL.md: list it without a description
126
182
  }
183
+ return { name: n, description, references: src.refs(n) };
127
184
  }),
128
185
  });
129
186
  return;
@@ -133,38 +190,54 @@ export function registerSkill(program: Command) {
133
190
  return;
134
191
  }
135
192
  const rows = names.map((n) => {
193
+ let description = "";
136
194
  try {
137
- const { meta } = readSkill(src, n);
138
- return [n, shortDesc(meta.description || "")];
195
+ description = shortDesc(readSkill(src, n).meta.description || "");
139
196
  } catch {
140
- return [n, ""];
197
+ // unreadable SKILL.md: list it without a description
141
198
  }
199
+ return [n, description, src.refs(n).join(", ")];
142
200
  });
143
- table(["name", "description"], rows);
201
+ table(["name", "description", "references"], rows);
144
202
  });
145
203
 
146
204
  skill
147
205
  .command("get")
148
- .description("Print a skill's full content (markdown)")
206
+ .description("Print a skill's content (markdown), or one of its references")
149
207
  .argument("<name>", "skill name (e.g. core)")
150
- .option(
151
- "--full",
152
- "Include reference files (reserved; currently equivalent to default)",
153
- )
154
- .action((name: string, _opts: { full?: boolean }) => {
208
+ .option("--ref <ref>", "print one reference instead (e.g. sandboxes, deploys, sdk)")
209
+ .option("--full", "append every reference after the skill")
210
+ .action((name: string, opts: { ref?: string; full?: boolean }) => {
155
211
  const src = getSource();
156
212
  try {
213
+ if (opts.ref) {
214
+ const md = readRef(src, name, opts.ref);
215
+ if (isJSONMode()) {
216
+ printJSON({ name, ref: opts.ref, path: src.pathOf(name, opts.ref), content: md });
217
+ return;
218
+ }
219
+ process.stdout.write(md.endsWith("\n") ? md : md + "\n");
220
+ return;
221
+ }
157
222
  const { md, meta } = readSkill(src, name);
223
+ let content = md;
224
+ if (opts.full) {
225
+ for (const ref of src.refs(name)) {
226
+ const body = src.readRef(name, ref) ?? "";
227
+ content = `${content.trimEnd()}\n\n---\n<!-- references/${ref}.md -->\n\n${body}`;
228
+ }
229
+ }
158
230
  if (isJSONMode()) {
159
231
  printJSON({
160
232
  name,
161
233
  path: src.pathOf(name),
162
234
  frontmatter: meta,
163
- content: md,
235
+ content,
236
+ references: refEntries(src, name),
164
237
  });
165
238
  return;
166
239
  }
167
- process.stdout.write(md.endsWith("\n") ? md : md + "\n");
240
+ process.stdout.write(content.endsWith("\n") ? content : content + "\n");
168
241
  } catch (e: any) {
169
242
  fail(e.message, 3);
170
243
  }
@@ -174,9 +247,10 @@ export function registerSkill(program: Command) {
174
247
  .command("path")
175
248
  .description("Print the filesystem path of a skill (or the skills root)")
176
249
  .argument("[name]", "skill name; omit to print the skills directory")
177
- .action((name?: string) => {
250
+ .option("--ref <ref>", "print the path of one reference instead")
251
+ .action((name: string | undefined, opts: { ref?: string }) => {
178
252
  const src = getSource();
179
- const target = src.pathOf(name);
253
+ const target = src.pathOf(name, name ? opts.ref : undefined);
180
254
  if (isJSONMode()) {
181
255
  printJSON({ path: target });
182
256
  return;
@@ -6,7 +6,7 @@ import { createTarball } from "../lib/archive.js";
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import * as readline from "node:readline";
9
- import { api, streamSSE, getBaseURL, clientHeaders, APIError, apiErrorFrom, isPaymentRequired, type ResourceScope } from "../lib/api.js";
9
+ import { api, streamSSE, getBaseURL, clientHeaders, APIError, apiErrorFrom, isPaymentRequired, withScope, type ResourceScope } from "../lib/api.js";
10
10
  import { updateProjectLink, DEFAULT_REGION } from "../lib/config.js";
11
11
  import { resolveContext, getScope } from "../lib/resolve.js";
12
12
  import { ensureLinked } from "./init.js";
@@ -62,11 +62,25 @@ export function registerUp(program: Command) {
62
62
  // An unlinked folder goes to the default project (or --project, or init's questions)
63
63
  await ensureLinked({ projectName: projectFlag, useDefault: true });
64
64
 
65
- // Resolve target service: --service flag → linked → first-in-project → prompt-or-fail
66
- const ctx = await resolveContext({
67
- projectFlag,
68
- serviceFlag,
69
- });
65
+ // Resolve target service: --service flag → linked → first-in-project → prompt-or-fail.
66
+ // `--service <name>` that doesn't exist yet names the new service instead of failing:
67
+ // agents (and people) reach for it to choose the name of a first deploy.
68
+ let ctx: Awaited<ReturnType<typeof resolveContext>>;
69
+ try {
70
+ ctx = await resolveContext({ projectFlag, serviceFlag });
71
+ } catch (e: any) {
72
+ if (!serviceFlag || !/not found in project/.test(String(e?.message))) throw e;
73
+ ctx = { ...(await resolveContext({ projectFlag })), service: undefined };
74
+ info(chalk.dim(`Service ${serviceFlag} doesn't exist yet: creating it.`));
75
+ }
76
+ // A folder linked to an addon (`lizard add postgres` links it) must not send code there.
77
+ if (ctx.service) {
78
+ const services = await api.get<{ addons?: { id: string }[] }>(withScope(`/api/projects/${ctx.projectId}/services`, getScope(ctx)));
79
+ if (services.addons?.some((a) => a.id === ctx.service!.id)) {
80
+ if (serviceFlag) throw new Error(`${ctx.service.name} is a managed addon, not a service you deploy code to. Pass the app's --service.`);
81
+ ctx = { ...ctx, service: undefined };
82
+ }
83
+ }
70
84
  const projectId = ctx.projectId;
71
85
  const scope = getScope(ctx);
72
86
 
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { Command } from "commander";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { info, success, isJSONMode, printJSON } from "../lib/format.js";
4
5
  import {
5
6
  CURRENT_VERSION,
@@ -75,8 +76,17 @@ export function registerUpgrade(program: Command) {
75
76
  }
76
77
 
77
78
  // npm install — self-replacing process.execPath would overwrite the
78
- // user's node binary. Point at npm instead.
79
+ // user's node binary, so let npm do it. Agents on an old copy (sandbox templates
80
+ // ship one) need the upgrade to happen, not a hint they may not follow.
79
81
  if (!isStandaloneBinary()) {
82
+ const spec = `@lizard-build/cli@${latest}`;
83
+ if (!isJSONMode()) info(`Upgrading v${CURRENT_VERSION} → ${chalk.green("v" + latest)} with npm...`);
84
+ const r = spawnSync("npm", ["install", "-g", spec], { encoding: "utf8", shell: process.platform === "win32" });
85
+ if (r.status === 0) {
86
+ if (isJSONMode()) printJSON({ previousVersion: CURRENT_VERSION, latestVersion: latest, upgraded: true, method: "npm" });
87
+ else success(`Upgraded to v${latest}`);
88
+ return;
89
+ }
80
90
  if (isJSONMode()) {
81
91
  printJSON({
82
92
  currentVersion: CURRENT_VERSION,
@@ -85,11 +95,12 @@ export function registerUpgrade(program: Command) {
85
95
  upgraded: false,
86
96
  method: "npm",
87
97
  hint: "npm install -g @lizard-build/cli@latest",
98
+ error: ((r.stderr || "") + (r.error?.message ?? "")).trim().split("\n").slice(-3).join(" ") || null,
88
99
  });
89
100
  return;
90
101
  }
91
- info(`Update available: v${CURRENT_VERSION} → ${chalk.green("v" + latest)}`);
92
- info(`This copy was installed via npm. Upgrade with:`);
102
+ info(`npm could not upgrade this copy (${(r.stderr || r.error?.message || "").trim().split("\n").pop()}).`);
103
+ info(`Upgrade it yourself, with the rights npm needs:`);
93
104
  info(` ${chalk.cyan("npm install -g @lizard-build/cli@latest")}`);
94
105
  return;
95
106
  }