@higherdev/cli 0.4.0 → 0.5.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
@@ -12,17 +12,24 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
12
12
 
13
13
  ```json
14
14
  {
15
- "url": "https://hdx-higher-ops.vercel.app",
16
- "api_key": "hdx_...",
17
- "slug": "workspace"
15
+ "current": "workspace",
16
+ "workspaces": {
17
+ "workspace": {
18
+ "url": "https://hdx-higher-ops.vercel.app",
19
+ "api_key": "hdx_..."
20
+ }
21
+ }
18
22
  }
19
23
  ```
20
24
 
25
+ The previous single-workspace config shape is migrated automatically when it is read.
26
+
21
27
  ## Commands
22
28
 
23
29
  | Command | Purpose |
24
30
  | --- | --- |
25
- | `hd` | Show the HigherDEV banner and usage |
31
+ | `hd` | Open the live TUI when attached to a terminal |
32
+ | `hd --help` | Show the HigherDEV banner and usage |
26
33
  | `hd status` | Show workspace, ticket, run, and decision status |
27
34
  | `hd ticket list` | List tickets |
28
35
  | `hd ticket show KEY` | Show one ticket |
@@ -39,4 +46,6 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
39
46
  | `hd init [options]` | Configure this host and runner service |
40
47
  | `hd upgrade [options]` | Refresh this host configuration |
41
48
 
42
- Run `hd` for the compact command summary.
49
+ Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/decide`, `/agents`,
50
+ `/settings`, `/workspace`, `/feed`, `/orchestrator`, `/refresh`, `/help`, or
51
+ `/exit`. The display refreshes from the HDX API every five seconds.
package/dist/api.js CHANGED
@@ -22,24 +22,19 @@ async function request(config, method, path, body) {
22
22
  }
23
23
  return parsed;
24
24
  }
25
- export async function getStatus() {
26
- const config = loadConfig();
25
+ export async function getStatus(config = loadConfig()) {
27
26
  return request(config, "GET", `/api/w/${config.slug}/status`);
28
27
  }
29
- export async function listTickets() {
30
- const config = loadConfig();
28
+ export async function listTickets(config = loadConfig()) {
31
29
  return request(config, "GET", `/api/w/${config.slug}/tickets`);
32
30
  }
33
- export async function showTicket(key) {
34
- const config = loadConfig();
31
+ export async function showTicket(key, config = loadConfig()) {
35
32
  return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}`);
36
33
  }
37
- export async function createTicket(fields) {
38
- const config = loadConfig();
34
+ export async function createTicket(fields, config = loadConfig()) {
39
35
  return request(config, "POST", `/api/w/${config.slug}/tickets`, fields);
40
36
  }
41
- export async function listTicketEvents(key, afterAt, afterId) {
42
- const config = loadConfig();
37
+ export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
43
38
  const query = new URLSearchParams();
44
39
  if (afterAt)
45
40
  query.set("after_at", afterAt);
@@ -48,29 +43,43 @@ export async function listTicketEvents(key, afterAt, afterId) {
48
43
  const suffix = query.size ? `?${query.toString()}` : "";
49
44
  return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events${suffix}`);
50
45
  }
51
- export async function postMessage(fields) {
52
- const config = loadConfig();
46
+ export async function postMessage(fields, config = loadConfig()) {
53
47
  return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
54
48
  }
55
- export async function answerDecision(id, answer_md) {
56
- const config = loadConfig();
49
+ export async function answerDecision(id, answer_md, config = loadConfig()) {
57
50
  return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
58
51
  }
59
- export async function setPaused(paused) {
60
- const config = loadConfig();
52
+ export async function setPaused(paused, config = loadConfig()) {
61
53
  const path = paused ? `/api/w/${config.slug}/pause` : `/api/w/${config.slug}/resume`;
62
54
  return request(config, "POST", path);
63
55
  }
64
- export async function createWorkspace(fields) {
65
- const config = loadConfig();
56
+ export async function createWorkspace(fields, config = loadConfig()) {
66
57
  const result = await request(config, "POST", "/api/workspaces", fields);
67
58
  return { ...result, url: config.url };
68
59
  }
69
- export async function listAgents() {
70
- const config = loadConfig();
60
+ export async function listAgents(config = loadConfig()) {
71
61
  return request(config, "GET", `/api/w/${config.slug}/agents`);
72
62
  }
73
- export async function updateAgent(id, fields) {
74
- const config = loadConfig();
63
+ export async function updateAgent(id, fields, config = loadConfig()) {
75
64
  return request(config, "PATCH", `/api/w/${config.slug}/agents`, { id, ...fields });
76
65
  }
66
+ export async function listEpics(config = loadConfig()) {
67
+ return request(config, "GET", `/api/w/${config.slug}/epics`);
68
+ }
69
+ export async function listMessages(options = {}, config = loadConfig()) {
70
+ const query = new URLSearchParams();
71
+ if (options.ticketId)
72
+ query.set("ticket_id", options.ticketId);
73
+ if (options.since)
74
+ query.set("since", options.since);
75
+ if (options.limit)
76
+ query.set("limit", String(options.limit));
77
+ const suffix = query.size ? `?${query.toString()}` : "";
78
+ return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
79
+ }
80
+ export async function updateCaps(provider_caps, config = loadConfig()) {
81
+ return request(config, "PATCH", `/api/w/${config.slug}/caps`, { provider_caps });
82
+ }
83
+ export async function listFeed(config = loadConfig()) {
84
+ return request(config, "GET", `/api/w/${config.slug}/feed`);
85
+ }
package/dist/config.js CHANGED
@@ -1,26 +1,80 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, existsSync, 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 normalizeWorkspace(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 { "current": "workspace", "workspaces": { "workspace": { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_..." } } }`);
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
+ // 0.4 and earlier stored one workspace at the top level. Rewrite it once so
29
+ // every later read sees the multi-workspace format.
30
+ const legacy = normalizeWorkspace(parsed);
31
+ if (legacy && typeof parsed.slug === "string" && parsed.slug) {
32
+ const migrated = { current: parsed.slug, workspaces: { [parsed.slug]: legacy } };
33
+ writeStoredConfig(migrated, path);
34
+ return migrated;
35
+ }
36
+ const current = typeof parsed.current === "string" ? parsed.current : "";
37
+ const source = parsed.workspaces;
38
+ if (!current || !source || typeof source !== "object" || Array.isArray(source)) {
39
+ throw new Error(`${path} needs current and workspaces`);
40
+ }
41
+ const workspaces = {};
42
+ for (const [slug, value] of Object.entries(source)) {
43
+ const workspace = normalizeWorkspace(value);
44
+ if (workspace)
45
+ workspaces[slug] = workspace;
19
46
  }
20
- return { url: parsed.url.replace(/\/$/, ""), api_key: parsed.api_key, slug: parsed.slug };
47
+ if (!workspaces[current])
48
+ throw new Error(`${path} current workspace ${current} is not configured`);
49
+ return { current, workspaces };
21
50
  }
22
- export function writeHdConfig(config, path = configPath()) {
51
+ export function loadConfig(slug) {
52
+ const stored = loadStoredConfig();
53
+ const selected = slug ?? stored.current;
54
+ const workspace = stored.workspaces[selected];
55
+ if (!workspace)
56
+ throw new Error(`No configured workspace ${selected}.`);
57
+ return { ...workspace, slug: selected };
58
+ }
59
+ export function writeStoredConfig(config, path = configPath()) {
23
60
  mkdirSync(dirname(path), { recursive: true });
24
61
  writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
25
62
  chmodSync(path, 0o600);
26
63
  }
64
+ /** Store credentials for a workspace and make it current. */
65
+ export function writeHdConfig(config, path = configPath()) {
66
+ const stored = existsSync(path)
67
+ ? loadStoredConfig(path)
68
+ : { current: config.slug, workspaces: {} };
69
+ stored.workspaces[config.slug] = { url: config.url.replace(/\/$/, ""), api_key: config.api_key };
70
+ stored.current = config.slug;
71
+ writeStoredConfig(stored, path);
72
+ }
73
+ export function switchWorkspace(slug, path = configPath()) {
74
+ const stored = loadStoredConfig(path);
75
+ const workspace = stored.workspaces[slug];
76
+ if (!workspace)
77
+ throw new Error(`No configured workspace ${slug}.`);
78
+ writeStoredConfig({ ...stored, current: slug }, path);
79
+ return { ...workspace, slug };
80
+ }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { answerDecision, createTicket, createWorkspace, getStatus, listAgents, listTicketEvents, listTickets, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
+ import { 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);
@@ -184,7 +185,8 @@ async function cmdWorkspace(argv) {
184
185
  const result = await createWorkspace({ name: opts.name, repo: opts.repo, slug: opts.slug,
185
186
  default_branch: opts.branch ?? "main", default_host: opts.host ?? "box" });
186
187
  console.log(`workspace: ${result.workspace.slug}`);
187
- console.log("api key shown once; switch with:");
188
+ writeHdConfig({ url: result.url, api_key: result.api_key, slug: result.workspace.slug });
189
+ console.log("saved and switched; configure another machine with:");
188
190
  console.log(`hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}`);
189
191
  }
190
192
  function selectAgent(agents, role, provider) {
@@ -225,6 +227,18 @@ export async function main(argv = process.argv.slice(2)) {
225
227
  const [cmd, ...rest] = argv;
226
228
  try {
227
229
  if (!cmd) {
230
+ const { canLaunchApp } = await import("./tui/capability.js");
231
+ if (canLaunchApp()) {
232
+ const { launchApp } = await import("./tui/launch.js");
233
+ await launchApp();
234
+ return;
235
+ }
236
+ console.log(banner());
237
+ console.log("");
238
+ console.log(usage());
239
+ return;
240
+ }
241
+ if (cmd === "--help" || cmd === "-h") {
228
242
  console.log(banner());
229
243
  console.log("");
230
244
  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 = "…";