@higherdev/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,19 +14,24 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
14
14
  {
15
15
  "url": "https://hdx-higher-ops.vercel.app",
16
16
  "api_key": "hdx_...",
17
- "slug": "workspace"
17
+ "current": "workspace"
18
18
  }
19
19
  ```
20
20
 
21
+ One workspace key reaches every workspace. Previous single-workspace and
22
+ workspace-map config shapes are migrated automatically when they are read.
23
+
21
24
  ## Commands
22
25
 
23
26
  | Command | Purpose |
24
27
  | --- | --- |
25
- | `hd` | Show the HigherDEV banner and usage |
28
+ | `hd` | Open the live TUI when attached to a terminal |
29
+ | `hd --help` | Show the HigherDEV banner and usage |
26
30
  | `hd status` | Show workspace, ticket, run, and decision status |
27
31
  | `hd ticket list` | List tickets |
28
32
  | `hd ticket show KEY` | Show one ticket |
29
33
  | `hd ticket new --title TITLE [options]` | Create a ticket |
34
+ | `hd workspace ls` | List every workspace available to the configured key |
30
35
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Create a paused workspace |
31
36
  | `hd agents` | List agents |
32
37
  | `hd agents set ROLE --provider P --model M [--effort E]` | Update an agent |
@@ -39,4 +44,6 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
39
44
  | `hd init [options]` | Configure this host and runner service |
40
45
  | `hd upgrade [options]` | Refresh this host configuration |
41
46
 
42
- Run `hd` for the compact command summary.
47
+ Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/decide`, `/agents`,
48
+ `/settings`, `/workspace`, `/feed`, `/orchestrator`, `/refresh`, `/help`, or
49
+ `/exit`. The display refreshes from the HDX API every five seconds.
package/dist/api.js CHANGED
@@ -1,4 +1,19 @@
1
1
  import { loadConfig } from "./config.js";
2
+ export const MAX_API_ERROR_BODY = 500;
3
+ export function apiErrorMessage(status, parsed, fallback) {
4
+ const raw = parsed && typeof parsed === "object" && "error" in parsed
5
+ && typeof parsed.error === "string"
6
+ ? parsed.error : fallback;
7
+ const duplicate = raw.match(/agents_one_enabled_(orchestrator|reviewer)_idx/i)?.[1]?.toLowerCase();
8
+ if (duplicate) {
9
+ return `hd: ${status} Only one enabled ${duplicate} is allowed per workspace. Disable the current ${duplicate} before enabling another.`;
10
+ }
11
+ const trimmed = raw.trim();
12
+ const body = trimmed.length > MAX_API_ERROR_BODY
13
+ ? `${trimmed.slice(0, MAX_API_ERROR_BODY)}... [truncated ${trimmed.length - MAX_API_ERROR_BODY} chars]`
14
+ : trimmed;
15
+ return `hd: ${status} ${body}`;
16
+ }
2
17
  async function request(config, method, path, body) {
3
18
  const response = await fetch(`${config.url}${path}`, {
4
19
  method,
@@ -14,32 +29,26 @@ async function request(config, method, path, body) {
14
29
  parsed = text ? JSON.parse(text) : null;
15
30
  }
16
31
  catch {
17
- throw new Error(`hd: ${response.status} ${text || response.statusText}`);
32
+ throw new Error(apiErrorMessage(response.status, null, text || response.statusText));
18
33
  }
19
34
  if (!response.ok) {
20
- const err = parsed;
21
- throw new Error(`hd: ${response.status} ${err.error ?? text}`);
35
+ throw new Error(apiErrorMessage(response.status, parsed, text || response.statusText));
22
36
  }
23
37
  return parsed;
24
38
  }
25
- export async function getStatus() {
26
- const config = loadConfig();
39
+ export async function getStatus(config = loadConfig()) {
27
40
  return request(config, "GET", `/api/w/${config.slug}/status`);
28
41
  }
29
- export async function listTickets() {
30
- const config = loadConfig();
42
+ export async function listTickets(config = loadConfig()) {
31
43
  return request(config, "GET", `/api/w/${config.slug}/tickets`);
32
44
  }
33
- export async function showTicket(key) {
34
- const config = loadConfig();
45
+ export async function showTicket(key, config = loadConfig()) {
35
46
  return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}`);
36
47
  }
37
- export async function createTicket(fields) {
38
- const config = loadConfig();
48
+ export async function createTicket(fields, config = loadConfig()) {
39
49
  return request(config, "POST", `/api/w/${config.slug}/tickets`, fields);
40
50
  }
41
- export async function listTicketEvents(key, afterAt, afterId) {
42
- const config = loadConfig();
51
+ export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
43
52
  const query = new URLSearchParams();
44
53
  if (afterAt)
45
54
  query.set("after_at", afterAt);
@@ -48,29 +57,46 @@ export async function listTicketEvents(key, afterAt, afterId) {
48
57
  const suffix = query.size ? `?${query.toString()}` : "";
49
58
  return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events${suffix}`);
50
59
  }
51
- export async function postMessage(fields) {
52
- const config = loadConfig();
60
+ export async function postMessage(fields, config = loadConfig()) {
53
61
  return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
54
62
  }
55
- export async function answerDecision(id, answer_md) {
56
- const config = loadConfig();
63
+ export async function answerDecision(id, answer_md, config = loadConfig()) {
57
64
  return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
58
65
  }
59
- export async function setPaused(paused) {
60
- const config = loadConfig();
66
+ export async function setPaused(paused, config = loadConfig()) {
61
67
  const path = paused ? `/api/w/${config.slug}/pause` : `/api/w/${config.slug}/resume`;
62
68
  return request(config, "POST", path);
63
69
  }
64
- export async function createWorkspace(fields) {
65
- const config = loadConfig();
70
+ export async function createWorkspace(fields, config = loadConfig()) {
66
71
  const result = await request(config, "POST", "/api/workspaces", fields);
67
72
  return { ...result, url: config.url };
68
73
  }
69
- export async function listAgents() {
70
- const config = loadConfig();
74
+ export async function listWorkspaces(config = loadConfig()) {
75
+ return request(config, "GET", "/api/workspaces");
76
+ }
77
+ export async function listAgents(config = loadConfig()) {
71
78
  return request(config, "GET", `/api/w/${config.slug}/agents`);
72
79
  }
73
- export async function updateAgent(id, fields) {
74
- const config = loadConfig();
80
+ export async function updateAgent(id, fields, config = loadConfig()) {
75
81
  return request(config, "PATCH", `/api/w/${config.slug}/agents`, { id, ...fields });
76
82
  }
83
+ export async function listEpics(config = loadConfig()) {
84
+ return request(config, "GET", `/api/w/${config.slug}/epics`);
85
+ }
86
+ export async function listMessages(options = {}, config = loadConfig()) {
87
+ const query = new URLSearchParams();
88
+ if (options.ticketId)
89
+ query.set("ticket_id", options.ticketId);
90
+ if (options.since)
91
+ query.set("since", options.since);
92
+ if (options.limit)
93
+ query.set("limit", String(options.limit));
94
+ const suffix = query.size ? `?${query.toString()}` : "";
95
+ return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
96
+ }
97
+ export async function updateCaps(provider_caps, config = loadConfig()) {
98
+ return request(config, "PATCH", `/api/w/${config.slug}/caps`, { provider_caps });
99
+ }
100
+ export async function listFeed(config = loadConfig()) {
101
+ return request(config, "GET", `/api/w/${config.slug}/feed`);
102
+ }
package/dist/config.js CHANGED
@@ -4,23 +4,72 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  export function configPath() {
5
5
  return process.env.HD_CONFIG ?? join(homedir(), ".config/hd/config.json");
6
6
  }
7
- export function loadConfig() {
8
- const path = configPath();
7
+ function normalizeConnection(value) {
8
+ if (!value || typeof value !== "object" || Array.isArray(value))
9
+ return null;
10
+ const row = value;
11
+ if (typeof row.url !== "string" || typeof row.api_key !== "string" || !row.url || !row.api_key) {
12
+ return null;
13
+ }
14
+ return { url: row.url.replace(/\/$/, ""), api_key: row.api_key };
15
+ }
16
+ function missing(path) {
17
+ return new Error(`missing ${path}\nWrite { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_...", "current": "workspace" }`);
18
+ }
19
+ export function loadStoredConfig(path = configPath()) {
9
20
  let raw;
10
21
  try {
11
22
  raw = readFileSync(path, "utf8");
12
23
  }
13
24
  catch {
14
- throw new Error(`missing ${path}\nWrite { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_...", "slug": "workspace" }`);
25
+ throw missing(path);
15
26
  }
16
27
  const parsed = JSON.parse(raw);
17
- if (!parsed.url || !parsed.api_key || !parsed.slug) {
18
- throw new Error(`${path} needs url, api_key, and slug`);
28
+ const connection = normalizeConnection(parsed);
29
+ const current = typeof parsed.current === "string" ? parsed.current : "";
30
+ if (connection && current)
31
+ return { ...connection, current };
32
+ // 0.4 stored one workspace at the top level with `slug` rather than
33
+ // `current`. One key can now reach every workspace, so only the selected
34
+ // slug needs to survive.
35
+ if (connection && typeof parsed.slug === "string" && parsed.slug) {
36
+ const migrated = { ...connection, current: parsed.slug };
37
+ writeStoredConfig(migrated, path);
38
+ return migrated;
19
39
  }
20
- return { url: parsed.url.replace(/\/$/, ""), api_key: parsed.api_key, slug: parsed.slug };
40
+ // 0.5 stored one connection per workspace. Keep the current workspace's
41
+ // credentials as the shared operator connection and discard the duplicates.
42
+ const source = parsed.workspaces;
43
+ if (!current || !source || typeof source !== "object" || Array.isArray(source)) {
44
+ throw new Error(`${path} needs url, api_key, and current`);
45
+ }
46
+ const selected = normalizeConnection(source[current]);
47
+ if (!selected)
48
+ throw new Error(`${path} current workspace ${current} is not configured`);
49
+ const migrated = { ...selected, current };
50
+ writeStoredConfig(migrated, path);
51
+ return migrated;
21
52
  }
22
- export function writeHdConfig(config, path = configPath()) {
53
+ export function loadConfig(slug) {
54
+ const stored = loadStoredConfig();
55
+ const selected = slug ?? stored.current;
56
+ return { url: stored.url, api_key: stored.api_key, slug: selected };
57
+ }
58
+ export function writeStoredConfig(config, path = configPath()) {
23
59
  mkdirSync(dirname(path), { recursive: true });
24
60
  writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
25
61
  chmodSync(path, 0o600);
26
62
  }
63
+ /** Store one operator connection and make its selected workspace current. */
64
+ export function writeHdConfig(config, path = configPath()) {
65
+ writeStoredConfig({
66
+ url: config.url.replace(/\/$/, ""),
67
+ api_key: config.api_key,
68
+ current: config.slug,
69
+ }, path);
70
+ }
71
+ export function switchWorkspace(slug, path = configPath()) {
72
+ const stored = loadStoredConfig(path);
73
+ writeStoredConfig({ ...stored, current: slug }, path);
74
+ return { url: stored.url, api_key: stored.api_key, slug };
75
+ }
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
- import { answerDecision, createTicket, createWorkspace, getStatus, listAgents, listTicketEvents, listTickets, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
4
+ import { answerDecision, createTicket, createWorkspace, getStatus, listAgents, listTicketEvents, listTickets, listWorkspaces, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
+ import { loadConfig, writeHdConfig } from "./config.js";
6
7
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
7
8
  function fail(message) {
8
9
  console.error(message);
@@ -175,8 +176,20 @@ async function cmdDecide(argv) {
175
176
  }
176
177
  async function cmdWorkspace(argv) {
177
178
  const [action, ...rest] = argv;
179
+ if (action === "ls") {
180
+ const current = loadConfig().slug;
181
+ const workspaces = await listWorkspaces();
182
+ console.log(table(["", "SLUG", "NAME", "REPO", "STATE"], workspaces.map((workspace) => [
183
+ workspace.slug === current ? "*" : "",
184
+ workspace.slug,
185
+ workspace.name,
186
+ workspace.repo,
187
+ workspace.on ? c.green("on") : c.dim("off"),
188
+ ])));
189
+ return;
190
+ }
178
191
  if (action !== "new")
179
- fail("usage: hd workspace new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
192
+ fail("usage: hd workspace ls | new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
180
193
  const { opts } = flags(rest);
181
194
  if (!opts.name || !opts.repo) {
182
195
  fail("usage: hd workspace new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
@@ -184,7 +197,8 @@ async function cmdWorkspace(argv) {
184
197
  const result = await createWorkspace({ name: opts.name, repo: opts.repo, slug: opts.slug,
185
198
  default_branch: opts.branch ?? "main", default_host: opts.host ?? "box" });
186
199
  console.log(`workspace: ${result.workspace.slug}`);
187
- console.log("api key shown once; switch with:");
200
+ writeHdConfig({ url: result.url, api_key: result.api_key, slug: result.workspace.slug });
201
+ console.log("saved and switched; configure another machine with:");
188
202
  console.log(`hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}`);
189
203
  }
190
204
  function selectAgent(agents, role, provider) {
@@ -225,6 +239,18 @@ export async function main(argv = process.argv.slice(2)) {
225
239
  const [cmd, ...rest] = argv;
226
240
  try {
227
241
  if (!cmd) {
242
+ const { canLaunchApp } = await import("./tui/capability.js");
243
+ if (canLaunchApp()) {
244
+ const { launchApp } = await import("./tui/launch.js");
245
+ await launchApp();
246
+ return;
247
+ }
248
+ console.log(banner());
249
+ console.log("");
250
+ console.log(usage());
251
+ return;
252
+ }
253
+ if (cmd === "--help" || cmd === "-h") {
228
254
  console.log(banner());
229
255
  console.log("");
230
256
  console.log(usage());
@@ -0,0 +1,96 @@
1
+ import { statusTone } from "../tui/data.js";
2
+ import { c, DOT, ELLIPSIS, tone } from "./theme.js";
3
+ export function truncate(text, max) {
4
+ const flat = String(text ?? "")
5
+ .replace(/\s+/g, " ")
6
+ .trim();
7
+ if (flat.length <= max)
8
+ return flat;
9
+ return `${flat.slice(0, Math.max(0, max - 1))}${ELLIPSIS}`;
10
+ }
11
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
12
+ export function displayWidth(text) {
13
+ return text.replace(ANSI, "").length;
14
+ }
15
+ export function pad(text, width) {
16
+ const gap = width - displayWidth(text);
17
+ return gap > 0 ? text + " ".repeat(gap) : text;
18
+ }
19
+ export function padStart(text, width) {
20
+ const gap = width - displayWidth(text);
21
+ return gap > 0 ? " ".repeat(gap) + text : text;
22
+ }
23
+ /** Fixed-width table. Widths measure printable characters, not escape codes. */
24
+ export function table(columns, rows) {
25
+ if (rows.length === 0)
26
+ return c.dim("none");
27
+ const widths = columns.map((column, i) => Math.max(displayWidth(column.header), ...rows.map((row) => displayWidth(row[i] ?? ""))));
28
+ const line = (cells) => cells
29
+ .map((cell, i) => {
30
+ if (i === cells.length - 1 && columns[i]?.align !== "right")
31
+ return cell;
32
+ return columns[i]?.align === "right" ? padStart(cell, widths[i]) : pad(cell, widths[i]);
33
+ })
34
+ .join(" ")
35
+ .trimEnd();
36
+ return [c.dim(line(columns.map((column) => column.header))), ...rows.map(line)].join("\n");
37
+ }
38
+ export function statusChip(status) {
39
+ return tone(statusTone(status), status.replaceAll("_", " "));
40
+ }
41
+ export function stateDot(state) {
42
+ return tone(state, DOT);
43
+ }
44
+ export function relativeTime(iso, now = Date.now()) {
45
+ if (!iso)
46
+ return "never";
47
+ const at = Date.parse(iso);
48
+ if (Number.isNaN(at))
49
+ return "never";
50
+ const seconds = Math.round((now - at) / 1000);
51
+ if (seconds < 0)
52
+ return "just now";
53
+ if (seconds < 60)
54
+ return `${seconds}s ago`;
55
+ const minutes = Math.round(seconds / 60);
56
+ if (minutes < 60)
57
+ return `${minutes}m ago`;
58
+ const hours = Math.round(minutes / 60);
59
+ if (hours < 48)
60
+ return `${hours}h ago`;
61
+ return `${Math.round(hours / 24)}d ago`;
62
+ }
63
+ export function elapsed(from, to, now = Date.now()) {
64
+ if (!from)
65
+ return "";
66
+ const start = Date.parse(from);
67
+ if (Number.isNaN(start))
68
+ return "";
69
+ const parsedEnd = to ? Date.parse(to) : now;
70
+ const end = Number.isNaN(parsedEnd) ? now : parsedEnd;
71
+ const seconds = Math.max(0, Math.round((end - start) / 1000));
72
+ if (seconds < 60)
73
+ return `${seconds}s`;
74
+ const minutes = Math.floor(seconds / 60);
75
+ if (minutes < 60)
76
+ return `${minutes}m${seconds % 60 ? ` ${seconds % 60}s` : ""}`;
77
+ const hours = Math.floor(minutes / 60);
78
+ return `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
79
+ }
80
+ export function heading(text) {
81
+ return c.bold(text);
82
+ }
83
+ export function bullet(text) {
84
+ return `${c.dim("-")} ${text}`;
85
+ }
86
+ export function section(title, body) {
87
+ return `${heading(title)}\n${body}`;
88
+ }
89
+ /** A one-line horizontal bar for spend and progress. */
90
+ export function bar(value, max, width = 20) {
91
+ if (!(max > 0))
92
+ return c.dim("-".repeat(width));
93
+ const filled = Math.max(0, Math.min(width, Math.round((value / max) * width)));
94
+ return c.blue("█".repeat(filled)) + c.dim("░".repeat(width - filled));
95
+ }
96
+ export { DOT };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Terminal palette. The web app is light only by decision; a terminal is the
3
+ * user's own surface, so nothing here paints a background. Foreground and dim
4
+ * only, blue as the single accent, status colour confined to state.
5
+ */
6
+ const ESC = `${String.fromCharCode(27)}[`;
7
+ export function colorEnabled(stream = process.stdout) {
8
+ if (process.env.HIGHERDEV_FORCE_COLOR === "1")
9
+ return true;
10
+ if (process.env.NO_COLOR)
11
+ return false;
12
+ return Boolean(stream.isTTY);
13
+ }
14
+ const CODES = {
15
+ bold: 1,
16
+ dim: 2,
17
+ red: 31,
18
+ green: 32,
19
+ yellow: 33,
20
+ magenta: 35,
21
+ cyan: 36,
22
+ grey: 90,
23
+ blue: 94,
24
+ };
25
+ function wrap(name, text) {
26
+ if (!colorEnabled())
27
+ return text;
28
+ return `${ESC}${CODES[name]}m${text}${ESC}0m`;
29
+ }
30
+ export const c = {
31
+ bold: (t) => wrap("bold", t),
32
+ dim: (t) => wrap("dim", t),
33
+ blue: (t) => wrap("blue", t),
34
+ green: (t) => wrap("green", t),
35
+ yellow: (t) => wrap("yellow", t),
36
+ red: (t) => wrap("red", t),
37
+ grey: (t) => wrap("grey", t),
38
+ cyan: (t) => wrap("cyan", t),
39
+ magenta: (t) => wrap("magenta", t),
40
+ };
41
+ export function tone(value, text) {
42
+ switch (value) {
43
+ case "blue":
44
+ return c.blue(text);
45
+ case "success":
46
+ return c.green(text);
47
+ case "warning":
48
+ return c.yellow(text);
49
+ case "danger":
50
+ return c.red(text);
51
+ default:
52
+ return c.dim(text);
53
+ }
54
+ }
55
+ /** Ink colour name for the same tone, so plain and TUI output agree. */
56
+ export function inkColor(value) {
57
+ switch (value) {
58
+ case "blue":
59
+ return "blueBright";
60
+ case "success":
61
+ return "green";
62
+ case "warning":
63
+ return "yellow";
64
+ case "danger":
65
+ return "red";
66
+ default:
67
+ return "gray";
68
+ }
69
+ }
70
+ export const DOT = "●";
71
+ export const ARROW = "→";
72
+ export const BAR = "│";
73
+ export const ELLIPSIS = "…";
package/dist/out.js CHANGED
@@ -65,7 +65,7 @@ export function usage() {
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
67
  ` ${c.blue("hd ticket list | show | new")} ticket operations`,
68
- ` ${c.blue("hd workspace new")} create a paused workspace`,
68
+ ` ${c.blue("hd workspace ls | new")} list or create workspaces`,
69
69
  ` ${c.blue("hd agents [set]")} inspect or update agents`,
70
70
  ` ${c.blue("hd logs KEY [-f]")} run events`,
71
71
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,