@lizard-build/cli 0.1.0 → 0.3.30

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 (184) hide show
  1. package/.github/workflows/release.yml +90 -0
  2. package/AGENTS.md +113 -0
  3. package/README.md +41 -0
  4. package/dist/commands/add.js +318 -45
  5. package/dist/commands/add.js.map +1 -1
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +68 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/docs.d.ts +2 -0
  10. package/dist/commands/docs.js +13 -0
  11. package/dist/commands/docs.js.map +1 -0
  12. package/dist/commands/domain.d.ts +9 -0
  13. package/dist/commands/domain.js +195 -0
  14. package/dist/commands/domain.js.map +1 -0
  15. package/dist/commands/git.js +175 -36
  16. package/dist/commands/git.js.map +1 -1
  17. package/dist/commands/init.d.ts +24 -0
  18. package/dist/commands/init.js +128 -86
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/link.d.ts +7 -0
  21. package/dist/commands/link.js +104 -33
  22. package/dist/commands/link.js.map +1 -1
  23. package/dist/commands/login.js +4 -3
  24. package/dist/commands/login.js.map +1 -1
  25. package/dist/commands/logs.js +223 -30
  26. package/dist/commands/logs.js.map +1 -1
  27. package/dist/commands/open.js +3 -2
  28. package/dist/commands/open.js.map +1 -1
  29. package/dist/commands/port.d.ts +7 -0
  30. package/dist/commands/port.js +49 -0
  31. package/dist/commands/port.js.map +1 -0
  32. package/dist/commands/projects.js +36 -6
  33. package/dist/commands/projects.js.map +1 -1
  34. package/dist/commands/ps.js +32 -39
  35. package/dist/commands/ps.js.map +1 -1
  36. package/dist/commands/redeploy.js +48 -8
  37. package/dist/commands/redeploy.js.map +1 -1
  38. package/dist/commands/regions.js +2 -5
  39. package/dist/commands/regions.js.map +1 -1
  40. package/dist/commands/restart.js +84 -10
  41. package/dist/commands/restart.js.map +1 -1
  42. package/dist/commands/run.d.ts +9 -0
  43. package/dist/commands/run.js +61 -22
  44. package/dist/commands/run.js.map +1 -1
  45. package/dist/commands/scale.d.ts +10 -0
  46. package/dist/commands/scale.js +166 -0
  47. package/dist/commands/scale.js.map +1 -0
  48. package/dist/commands/secrets.js +200 -89
  49. package/dist/commands/secrets.js.map +1 -1
  50. package/dist/commands/service-set.d.ts +49 -0
  51. package/dist/commands/service-set.js +552 -0
  52. package/dist/commands/service-set.js.map +1 -0
  53. package/dist/commands/service-show.d.ts +11 -0
  54. package/dist/commands/service-show.js +44 -0
  55. package/dist/commands/service-show.js.map +1 -0
  56. package/dist/commands/service.d.ts +8 -0
  57. package/dist/commands/service.js +262 -0
  58. package/dist/commands/service.js.map +1 -0
  59. package/dist/commands/skill.d.ts +2 -0
  60. package/dist/commands/skill.js +146 -0
  61. package/dist/commands/skill.js.map +1 -0
  62. package/dist/commands/ssh.d.ts +2 -0
  63. package/dist/commands/ssh.js +161 -0
  64. package/dist/commands/ssh.js.map +1 -0
  65. package/dist/commands/status.d.ts +7 -0
  66. package/dist/commands/status.js +49 -38
  67. package/dist/commands/status.js.map +1 -1
  68. package/dist/commands/unlink.d.ts +5 -0
  69. package/dist/commands/unlink.js +18 -0
  70. package/dist/commands/unlink.js.map +1 -0
  71. package/dist/commands/up.d.ts +9 -0
  72. package/dist/commands/up.js +417 -0
  73. package/dist/commands/up.js.map +1 -0
  74. package/dist/commands/upgrade.d.ts +2 -0
  75. package/dist/commands/upgrade.js +79 -0
  76. package/dist/commands/upgrade.js.map +1 -0
  77. package/dist/commands/whoami.js +26 -6
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/commands/workspace.d.ts +8 -0
  80. package/dist/commands/workspace.js +36 -0
  81. package/dist/commands/workspace.js.map +1 -0
  82. package/dist/index.js +209 -82
  83. package/dist/index.js.map +1 -1
  84. package/dist/lib/api.d.ts +17 -2
  85. package/dist/lib/api.js +85 -51
  86. package/dist/lib/api.js.map +1 -1
  87. package/dist/lib/auth.d.ts +3 -11
  88. package/dist/lib/auth.js +16 -36
  89. package/dist/lib/auth.js.map +1 -1
  90. package/dist/lib/config.d.ts +36 -15
  91. package/dist/lib/config.js +71 -58
  92. package/dist/lib/config.js.map +1 -1
  93. package/dist/lib/format.d.ts +1 -0
  94. package/dist/lib/format.js +17 -4
  95. package/dist/lib/format.js.map +1 -1
  96. package/dist/lib/name.d.ts +11 -0
  97. package/dist/lib/name.js +26 -0
  98. package/dist/lib/name.js.map +1 -0
  99. package/dist/lib/picker.d.ts +32 -0
  100. package/dist/lib/picker.js +91 -0
  101. package/dist/lib/picker.js.map +1 -0
  102. package/dist/lib/resolve.d.ts +85 -0
  103. package/dist/lib/resolve.js +203 -0
  104. package/dist/lib/resolve.js.map +1 -0
  105. package/dist/lib/updater.d.ts +16 -0
  106. package/dist/lib/updater.js +102 -0
  107. package/dist/lib/updater.js.map +1 -0
  108. package/lizard-wrapper.sh +2 -0
  109. package/package.json +11 -3
  110. package/skill-data/core/SKILL.md +239 -0
  111. package/src/commands/add.ts +388 -56
  112. package/src/commands/config.ts +80 -0
  113. package/src/commands/docs.ts +15 -0
  114. package/src/commands/domain.ts +248 -0
  115. package/src/commands/git.ts +201 -40
  116. package/src/commands/init.ts +149 -100
  117. package/src/commands/link.ts +127 -35
  118. package/src/commands/login.ts +4 -3
  119. package/src/commands/logs.ts +283 -27
  120. package/src/commands/open.ts +3 -2
  121. package/src/commands/port.ts +57 -0
  122. package/src/commands/projects.ts +43 -6
  123. package/src/commands/ps.ts +39 -60
  124. package/src/commands/redeploy.ts +51 -10
  125. package/src/commands/regions.ts +2 -6
  126. package/src/commands/restart.ts +84 -10
  127. package/src/commands/run.ts +68 -24
  128. package/src/commands/scale.ts +216 -0
  129. package/src/commands/secrets.ts +277 -100
  130. package/src/commands/service-set.ts +669 -0
  131. package/src/commands/service-show.ts +52 -0
  132. package/src/commands/service.ts +298 -0
  133. package/src/commands/skill.ts +157 -0
  134. package/src/commands/ssh.ts +176 -0
  135. package/src/commands/status.ts +51 -46
  136. package/src/commands/unlink.ts +17 -0
  137. package/src/commands/up.ts +461 -0
  138. package/src/commands/upgrade.ts +87 -0
  139. package/src/commands/whoami.ts +34 -6
  140. package/src/commands/workspace.ts +44 -0
  141. package/src/index.ts +219 -85
  142. package/src/lib/api.ts +114 -51
  143. package/src/lib/auth.ts +22 -46
  144. package/src/lib/config.ts +100 -65
  145. package/src/lib/format.ts +18 -4
  146. package/src/lib/name.ts +27 -0
  147. package/src/lib/picker.ts +133 -0
  148. package/src/lib/resolve.ts +285 -0
  149. package/src/lib/updater.ts +106 -0
  150. package/test/cli.test.ts +491 -0
  151. package/test/fixtures/hello-app/Dockerfile +5 -0
  152. package/test/fixtures/hello-app/index.js +5 -0
  153. package/test/unit/api.test.ts +66 -0
  154. package/test/unit/config.test.ts +94 -0
  155. package/test/unit/init.test.ts +211 -0
  156. package/test/unit/json.test.ts +208 -0
  157. package/test/unit/picker.test.ts +161 -0
  158. package/test/unit/resolve.test.ts +124 -0
  159. package/test/unit/service-set.test.ts +355 -0
  160. package/vitest.config.ts +10 -0
  161. package/dist/commands/connect.d.ts +0 -2
  162. package/dist/commands/connect.js +0 -117
  163. package/dist/commands/connect.js.map +0 -1
  164. package/dist/commands/context.d.ts +0 -2
  165. package/dist/commands/context.js +0 -71
  166. package/dist/commands/context.js.map +0 -1
  167. package/dist/commands/deploy.d.ts +0 -2
  168. package/dist/commands/deploy.js +0 -120
  169. package/dist/commands/deploy.js.map +0 -1
  170. package/dist/commands/destroy.d.ts +0 -2
  171. package/dist/commands/destroy.js +0 -51
  172. package/dist/commands/destroy.js.map +0 -1
  173. package/dist/commands/update.d.ts +0 -2
  174. package/dist/commands/update.js +0 -41
  175. package/dist/commands/update.js.map +0 -1
  176. package/dist/commands/version.d.ts +0 -2
  177. package/dist/commands/version.js +0 -37
  178. package/dist/commands/version.js.map +0 -1
  179. package/src/commands/connect.ts +0 -145
  180. package/src/commands/context.ts +0 -93
  181. package/src/commands/deploy.ts +0 -153
  182. package/src/commands/destroy.ts +0 -51
  183. package/src/commands/update.ts +0 -44
  184. package/src/commands/version.ts +0 -37
@@ -1,27 +1,55 @@
1
1
  import chalk from "chalk";
2
2
  import { Command } from "commander";
3
3
  import { api } from "../lib/api.js";
4
+ import { getProjectLink } from "../lib/config.js";
4
5
  import { isJSONMode, printJSON } from "../lib/format.js";
5
6
 
6
7
  export function registerWhoami(program: Command) {
7
8
  program
8
9
  .command("whoami")
9
- .description("Show current user")
10
+ .description("Show current user, active workspace, and linked project")
10
11
  .action(async () => {
11
12
  const user = await api.get<{
12
13
  id: string;
13
14
  username: string;
14
15
  avatarUrl?: string;
15
16
  hasGithubApp?: boolean;
17
+ activeWorkspaceId?: string | null;
18
+ activeWorkspaceName?: string | null;
19
+ defaultWorkspaceId?: string | null;
16
20
  }>("/api/auth/me");
17
21
 
22
+ const link = getProjectLink();
23
+ const project = link
24
+ ? {
25
+ id: link.projectId,
26
+ name: link.projectName,
27
+ workspaceId: link.workspaceId ?? null,
28
+ workspaceName: link.workspaceName ?? null,
29
+ }
30
+ : null;
31
+
18
32
  if (isJSONMode()) {
19
- printJSON(user);
33
+ printJSON({ ...user, project });
34
+ return;
35
+ }
36
+
37
+ console.log(chalk.bold(user.username));
38
+ if (user.hasGithubApp) {
39
+ console.log(chalk.dim("GitHub App: connected"));
40
+ }
41
+ if (user.activeWorkspaceName) {
42
+ console.log(chalk.dim("Workspace: ") + user.activeWorkspaceName);
43
+ }
44
+
45
+ if (project) {
46
+ const label = project.name || project.id;
47
+ const wsTag = project.workspaceName ? chalk.dim(` (${project.workspaceName})`) : "";
48
+ console.log(chalk.dim("Project: ") + label + wsTag + chalk.dim(" (linked here)"));
20
49
  } else {
21
- console.log(chalk.bold(user.username));
22
- if (user.hasGithubApp) {
23
- console.log(chalk.dim("GitHub App: connected"));
24
- }
50
+ console.log(
51
+ chalk.dim("Project: none — run `lizard init` in a project directory"),
52
+ );
25
53
  }
26
54
  });
27
55
  }
@@ -0,0 +1,44 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { fetchWorkspaces } from "../lib/picker.js";
4
+ import { isJSONMode, printJSON, table } from "../lib/format.js";
5
+
6
+ /**
7
+ * `lizard workspace` — workspace info.
8
+ *
9
+ * Member management (invite/remove/rename) intentionally lives in the
10
+ * dashboard, not here, to keep CLI surface narrow (Railway model).
11
+ */
12
+ export function registerWorkspace(program: Command) {
13
+ const ws = program
14
+ .command("workspace")
15
+ .description("Workspace info");
16
+
17
+ ws.command("list")
18
+ .alias("ls")
19
+ .description("List workspaces you belong to")
20
+ .action(async () => {
21
+ const list = await fetchWorkspaces();
22
+
23
+ if (isJSONMode()) {
24
+ printJSON(list);
25
+ return;
26
+ }
27
+
28
+ if (list.length === 0) {
29
+ console.log("No workspaces. The backend should always return a personal workspace.");
30
+ return;
31
+ }
32
+
33
+ table(
34
+ ["Name", "Slug", "Role", "Projects", "Personal"],
35
+ list.map((w) => [
36
+ w.name,
37
+ w.slug,
38
+ w.role,
39
+ String(w.projectCount ?? 0),
40
+ w.isPersonal ? chalk.green("✓") : "",
41
+ ]),
42
+ );
43
+ });
44
+ }
package/src/index.ts CHANGED
@@ -1,126 +1,252 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from "commander";
4
+ import chalk from "chalk";
4
5
  import { setJSONMode, isJSONMode, error } from "./lib/format.js";
5
- import { setTokenOverride, requireAuth, isLoggedIn } from "./lib/auth.js";
6
- import { setBaseURL, setAccessToken } from "./lib/api.js";
6
+ import { requireAuth, isLoggedIn } from "./lib/auth.js";
7
+ import { setBaseURL, setAccessToken, APIError } from "./lib/api.js";
8
+ import { checkForUpdateInBackground, CURRENT_VERSION } from "./lib/updater.js";
7
9
 
8
- // Commands
9
- import { registerLogin } from "./commands/login.js";
10
- import { registerLogout } from "./commands/logout.js";
11
- import { registerWhoami } from "./commands/whoami.js";
10
+ const BANNER = chalk.rgb(16, 185, 129)(
11
+ [
12
+ "╔═══════════════════════════════════════════════════╗",
13
+ "║ ║",
14
+ "║ ██╗ ██╗███████╗ █████╗ ██████╗ ██████╗ ║",
15
+ "║ ██║ ██║╚══███╔╝██╔══██╗██╔══██╗██╔══██╗ ║",
16
+ "║ ██║ ██║ ███╔╝ ███████║██████╔╝██║ ██║ ║",
17
+ "║ ██║ ██║ ███╔╝ ██╔══██║██╔══██╗██║ ██║ ║",
18
+ "║ ███████╗██║███████╗██║ ██║██║ ██║██████╔╝ ║",
19
+ "║ ╚══════╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ║",
20
+ "║ ║",
21
+ "╚═══════════════════════════════════════════════════╝",
22
+ ].join("\n"),
23
+ );
24
+
25
+ // Commands (alphabetical by command name)
26
+ import { registerAdd } from "./commands/add.js";
27
+ import { registerConfig } from "./commands/config.js";
28
+ import { registerDocs } from "./commands/docs.js";
29
+ import { registerDomain } from "./commands/domain.js";
30
+ import { registerGit } from "./commands/git.js";
12
31
  import { registerInit } from "./commands/init.js";
13
32
  import { registerLink } from "./commands/link.js";
33
+ import { registerLogin } from "./commands/login.js";
34
+ import { registerLogout } from "./commands/logout.js";
35
+ import { registerLogs } from "./commands/logs.js";
36
+ import { registerOpen } from "./commands/open.js";
37
+ import { registerPort } from "./commands/port.js";
14
38
  import { registerProjects } from "./commands/projects.js";
15
- import { registerDeploy } from "./commands/deploy.js";
16
39
  import { registerPs } from "./commands/ps.js";
17
- import { registerAdd } from "./commands/add.js";
18
- import { registerDestroy } from "./commands/destroy.js";
19
- import { registerRestart } from "./commands/restart.js";
20
40
  import { registerRedeploy } from "./commands/redeploy.js";
21
- import { registerLogs } from "./commands/logs.js";
22
- import { registerSecrets } from "./commands/secrets.js";
23
41
  import { registerRegions } from "./commands/regions.js";
24
- import { registerStatus } from "./commands/status.js";
25
- import { registerOpen } from "./commands/open.js";
42
+ import { registerRestart } from "./commands/restart.js";
26
43
  import { registerRun } from "./commands/run.js";
27
- import { registerConnect } from "./commands/connect.js";
28
- import { registerContext } from "./commands/context.js";
29
- import { registerGit } from "./commands/git.js";
30
- import { registerVersion } from "./commands/version.js";
31
- import { registerUpdate } from "./commands/update.js";
44
+ import { registerScale } from "./commands/scale.js";
45
+ import { registerSecrets } from "./commands/secrets.js";
46
+ import { registerService } from "./commands/service.js";
47
+ import { registerSkill } from "./commands/skill.js";
48
+ import { registerSSH } from "./commands/ssh.js";
49
+ import { registerStatus } from "./commands/status.js";
50
+ import { registerUnlink } from "./commands/unlink.js";
51
+ import { registerUp } from "./commands/up.js";
52
+ import { registerUpgrade } from "./commands/upgrade.js";
53
+ import { registerWhoami } from "./commands/whoami.js";
54
+ import { registerWorkspace } from "./commands/workspace.js";
32
55
 
33
56
  const program = new Command();
34
57
 
35
58
  program
36
59
  .name("lizard")
37
60
  .description("Lizard CLI — deploy and manage apps on Lizard")
38
- .version("0.1.0")
39
- .option("--json", "Output in JSON format")
40
- .option("-y, --yes", "Skip confirmation prompts")
41
- .option("--workspace <id>", "Workspace name or ID")
42
- .option("--project <id>", "Project ID")
43
- .option("--environment <name>", "Environment name")
44
- .option("--region <region>", "Region for creating services")
45
- .option("--token <token>", "API token")
46
- .option("--no-color", "Disable colors")
47
- .option("--verbose", "Verbose output")
61
+ .version(CURRENT_VERSION)
62
+ .addHelpText("before", BANNER + "\n")
63
+ .configureHelp({
64
+ subcommandTerm: (cmd) => {
65
+ const alias = cmd.aliases()[0];
66
+ return alias ? `${cmd.name()}|${alias}` : cmd.name();
67
+ },
68
+ })
69
+ .option("--json", "Output in JSON format (combine with --help to dump machine-readable command schema for agents)")
48
70
  .hook("preAction", async (thisCommand, actionCommand) => {
49
71
  const opts = thisCommand.opts();
50
72
 
73
+ // Check for updates silently in background (shows notice after command)
74
+ if (actionCommand.name() !== "upgrade") {
75
+ checkForUpdateInBackground();
76
+ }
77
+
51
78
  // JSON mode: explicit flag or non-TTY stdout
52
79
  if (opts.json || !process.stdout.isTTY) {
53
80
  setJSONMode(true);
54
81
  }
55
82
 
56
- // Token override
57
- if (opts.token) {
58
- setTokenOverride(opts.token);
59
- }
60
-
61
83
  // API URL override
62
84
  if (process.env.LIZARD_API_URL) {
63
85
  setBaseURL(process.env.LIZARD_API_URL);
64
86
  }
65
87
 
66
- // Commands that don't need auth
67
- const noAuth = new Set(["login", "logout", "version", "completion", "update", "help"]);
68
- if (noAuth.has(actionCommand.name())) return;
88
+ // Top-level commands that don't need auth. `status` prints the local link;
89
+ // the optional workspace backfill silently no-ops when not authed.
90
+ //
91
+ // Walk up to the top-level ancestor so subcommands inherit (`skill list`
92
+ // matches via `skill`). Leaf names like `git status` don't false-positive
93
+ // because we check the ancestor's name, not the action's.
94
+ const noAuth = new Set(["login", "logout", "upgrade", "help", "docs", "status", "skill"]);
95
+ let topLevel: Command = actionCommand;
96
+ while (topLevel.parent && topLevel.parent !== thisCommand) {
97
+ topLevel = topLevel.parent;
98
+ }
99
+ if (noAuth.has(topLevel.name())) return;
69
100
 
70
101
  // Require auth — auto-triggers login flow if not logged in
71
102
  const creds = await requireAuth();
72
103
  setAccessToken(creds.accessToken);
73
104
  });
74
105
 
75
- // Register all commands
76
- registerLogin(program);
77
- registerLogout(program);
78
- registerWhoami(program);
106
+ // Register all commands (alphabetical)
107
+ registerAdd(program);
108
+ registerConfig(program);
109
+ registerDocs(program);
110
+ registerDomain(program);
111
+ registerGit(program);
79
112
  registerInit(program);
80
113
  registerLink(program);
114
+ registerLogin(program);
115
+ registerLogout(program);
116
+ registerLogs(program);
117
+ registerOpen(program);
118
+ registerPort(program);
81
119
  registerProjects(program);
82
- registerDeploy(program);
83
120
  registerPs(program);
84
- registerAdd(program);
85
- registerDestroy(program);
86
- registerRestart(program);
87
121
  registerRedeploy(program);
88
- registerLogs(program);
89
- registerSecrets(program);
90
122
  registerRegions(program);
91
- registerStatus(program);
92
- registerOpen(program);
123
+ registerRestart(program);
93
124
  registerRun(program);
94
- registerConnect(program);
95
- registerContext(program);
96
- registerGit(program);
97
- registerVersion(program);
98
- registerUpdate(program);
99
-
100
- // Shell completion
101
- program
102
- .command("completion")
103
- .argument("<shell>", "Shell type (bash, zsh, fish)")
104
- .description("Generate shell completion script")
105
- .action((shell: string) => {
106
- // Commander doesn't have built-in completion like Cobra
107
- // Point users to manual setup
108
- console.log(`# Add to your .${shell}rc:`);
109
- if (shell === "bash") {
110
- console.log(`eval "$(lizard completion bash)"`);
111
- } else if (shell === "zsh") {
112
- console.log(`# lizard completion is not yet available for zsh`);
113
- console.log(`# Coming soon`);
114
- } else if (shell === "fish") {
115
- console.log(`# lizard completion is not yet available for fish`);
116
- console.log(`# Coming soon`);
117
- }
118
- });
125
+ registerScale(program);
126
+ registerSecrets(program);
127
+ registerService(program);
128
+ registerSkill(program);
129
+ registerSSH(program);
130
+ registerStatus(program);
131
+ registerUnlink(program);
132
+ registerUp(program);
133
+ registerUpgrade(program);
134
+ registerWhoami(program);
135
+ registerWorkspace(program);
119
136
 
120
137
  // Error handling
121
138
  program.exitOverride();
122
139
 
140
+ const EXIT_CODES: Record<string, string> = {
141
+ "0": "success",
142
+ "1": "generic error",
143
+ "2": "auth (401/403)",
144
+ "3": "not found (404)",
145
+ "4": "timeout (408/504)",
146
+ "5": "cancelled by user",
147
+ };
148
+
149
+ function isHelpJsonRequest(argv: string[]): boolean {
150
+ const hasHelp = argv.some((a) => a === "--help" || a === "-h");
151
+ const hasJson = argv.some((a) => a === "--json");
152
+ return hasHelp && hasJson;
153
+ }
154
+
155
+ function collectValueFlags(cmd: Command, acc: Set<string>) {
156
+ for (const opt of cmd.options as any[]) {
157
+ if (opt.required || opt.optional) {
158
+ if (opt.short) acc.add(opt.short);
159
+ if (opt.long) acc.add(opt.long);
160
+ }
161
+ }
162
+ for (const sub of cmd.commands) collectValueFlags(sub, acc);
163
+ }
164
+
165
+ function findTargetCommand(argv: string[], root: Command): Command {
166
+ const valueFlags = new Set<string>();
167
+ collectValueFlags(root, valueFlags);
168
+
169
+ let cur: Command = root;
170
+ for (let i = 2; i < argv.length; i++) {
171
+ const tok = argv[i];
172
+ if (tok === "--help" || tok === "-h" || tok === "--json") continue;
173
+ if (tok.startsWith("-")) {
174
+ if (tok.includes("=")) continue;
175
+ if (valueFlags.has(tok) && i + 1 < argv.length) i++;
176
+ continue;
177
+ }
178
+ const sub = cur.commands.find(
179
+ (c) => c.name() === tok || c.aliases().includes(tok),
180
+ );
181
+ if (!sub) break;
182
+ cur = sub;
183
+ }
184
+ return cur;
185
+ }
186
+
187
+ function dumpOption(o: any) {
188
+ return {
189
+ flags: o.flags,
190
+ long: o.long ?? null,
191
+ short: o.short ?? null,
192
+ description: o.description ?? "",
193
+ takesValue: Boolean(o.required || o.optional),
194
+ valueRequired: Boolean(o.required),
195
+ defaultValue: o.defaultValue ?? null,
196
+ choices: o.argChoices ?? null,
197
+ negate: Boolean(o.negate),
198
+ };
199
+ }
200
+
201
+ function dumpCommand(cmd: Command): any {
202
+ const args = ((cmd as any).registeredArguments ?? []).map((a: any) => ({
203
+ name: a.name(),
204
+ description: a.description ?? "",
205
+ required: Boolean(a.required),
206
+ variadic: Boolean(a.variadic),
207
+ defaultValue: a.defaultValue ?? null,
208
+ choices: a.argChoices ?? null,
209
+ }));
210
+ return {
211
+ name: cmd.name(),
212
+ aliases: cmd.aliases(),
213
+ description: cmd.description(),
214
+ usage: cmd.usage(),
215
+ arguments: args,
216
+ options: (cmd.options as any[])
217
+ .filter((o) => !o.hidden)
218
+ .map(dumpOption),
219
+ subcommands: cmd.commands
220
+ .filter((c: any) => !c._hidden)
221
+ .map(dumpCommand),
222
+ };
223
+ }
224
+
123
225
  async function main() {
226
+ // Set JSON mode from argv *before* parseAsync so the catch block below
227
+ // honors --json even when commander rejects before our preAction hook
228
+ // fires (e.g. unknown command, malformed global flag). Non-TTY auto-mode
229
+ // stays in preAction — `lizard --help | less` shouldn't suddenly emit JSON.
230
+ if (process.argv.includes("--json")) {
231
+ setJSONMode(true);
232
+ }
233
+
234
+ if (isHelpJsonRequest(process.argv)) {
235
+ const target = findTargetCommand(process.argv, program);
236
+ const isRoot = target === program;
237
+ const out = {
238
+ cli: "lizard",
239
+ version: CURRENT_VERSION,
240
+ command: dumpCommand(target),
241
+ globalOptions: isRoot
242
+ ? []
243
+ : (program.options as any[]).filter((o) => !o.hidden).map(dumpOption),
244
+ exitCodes: EXIT_CODES,
245
+ };
246
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
247
+ process.exit(0);
248
+ }
249
+
124
250
  try {
125
251
  await program.parseAsync(process.argv);
126
252
  } catch (err: any) {
@@ -133,14 +259,19 @@ async function main() {
133
259
  }
134
260
 
135
261
  const msg = err.message || String(err);
262
+ const apiErr = err instanceof APIError ? err : undefined;
263
+ const status = apiErr?.status;
264
+ const code = apiErr?.code || err.code || "ERROR";
136
265
 
137
266
  if (isJSONMode()) {
138
267
  console.log(
139
268
  JSON.stringify(
140
269
  {
141
270
  error: {
142
- code: err.code || "ERROR",
271
+ code,
272
+ status: status ?? null,
143
273
  message: msg,
274
+ body: apiErr?.body ?? null,
144
275
  },
145
276
  },
146
277
  null,
@@ -151,16 +282,19 @@ async function main() {
151
282
  error(msg);
152
283
  }
153
284
 
154
- // Exit codes per spec
155
- if (msg.includes("Not authenticated") || msg.includes("Invalid token")) {
156
- process.exit(2);
157
- }
158
- if (msg.includes("not found") || msg.includes("Not found")) {
159
- process.exit(3);
160
- }
161
- if (msg.includes("timeout") || msg.includes("Timeout")) {
162
- process.exit(4);
163
- }
285
+ // Exit codes derived from APIError.status (or tagged error codes), not message text
286
+ const isAuth = status === 401 || status === 403 || code === "NOT_AUTHENTICATED";
287
+ const isNotFound = status === 404;
288
+ const isTimeout =
289
+ status === 408 ||
290
+ status === 504 ||
291
+ err.name === "AbortError" ||
292
+ err.code === "ETIMEDOUT" ||
293
+ err.code === "UND_ERR_CONNECT_TIMEOUT";
294
+
295
+ if (isAuth) process.exit(2);
296
+ if (isNotFound) process.exit(3);
297
+ if (isTimeout) process.exit(4);
164
298
  process.exit(1);
165
299
  }
166
300
  }