@higherdev/cli 0.6.0 → 0.7.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
@@ -30,7 +30,10 @@ workspace-map config shapes are migrated automatically when they are read.
30
30
  | `hd status` | Show workspace, ticket, run, and decision status |
31
31
  | `hd ticket list` | List tickets |
32
32
  | `hd ticket show KEY` | Show one ticket |
33
- | `hd ticket new --title TITLE [options]` | Create a ticket |
33
+ | `hd ticket new --title TITLE [--acceptance TEXT] [options]` | Create a ticket |
34
+ | `hd ticket queue KEY` | Queue a complete ticket now |
35
+ | `hd epic new PATH [--title TITLE]` | Create an epic from a Markdown spec |
36
+ | `hd epic list` | List epics and ticket progress |
34
37
  | `hd workspace ls` | List every workspace available to the configured key |
35
38
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Create a paused workspace |
36
39
  | `hd agents` | List agents |
@@ -44,6 +47,7 @@ workspace-map config shapes are migrated automatically when they are read.
44
47
  | `hd init [options]` | Configure this host and runner service |
45
48
  | `hd upgrade [options]` | Refresh this host configuration |
46
49
 
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.
50
+ Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/epic new`,
51
+ `/epics`, `/decide`, `/agents`, `/settings`, `/workspace`, `/feed`,
52
+ `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
53
+ the HDX API every five seconds.
package/dist/api.js CHANGED
@@ -14,7 +14,7 @@ export function apiErrorMessage(status, parsed, fallback) {
14
14
  : trimmed;
15
15
  return `hd: ${status} ${body}`;
16
16
  }
17
- async function request(config, method, path, body) {
17
+ async function request(config, method, path, body, verbatimError = false) {
18
18
  const response = await fetch(`${config.url}${path}`, {
19
19
  method,
20
20
  headers: {
@@ -32,6 +32,10 @@ async function request(config, method, path, body) {
32
32
  throw new Error(apiErrorMessage(response.status, null, text || response.statusText));
33
33
  }
34
34
  if (!response.ok) {
35
+ if (verbatimError && parsed && typeof parsed === "object" && "error" in parsed
36
+ && typeof parsed.error === "string") {
37
+ throw new Error(parsed.error);
38
+ }
35
39
  throw new Error(apiErrorMessage(response.status, parsed, text || response.statusText));
36
40
  }
37
41
  return parsed;
@@ -48,6 +52,9 @@ export async function showTicket(key, config = loadConfig()) {
48
52
  export async function createTicket(fields, config = loadConfig()) {
49
53
  return request(config, "POST", `/api/w/${config.slug}/tickets`, fields);
50
54
  }
55
+ export async function queueTicket(key, config = loadConfig()) {
56
+ return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/queue`, undefined, true);
57
+ }
51
58
  export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
52
59
  const query = new URLSearchParams();
53
60
  if (afterAt)
@@ -83,6 +90,9 @@ export async function updateAgent(id, fields, config = loadConfig()) {
83
90
  export async function listEpics(config = loadConfig()) {
84
91
  return request(config, "GET", `/api/w/${config.slug}/epics`);
85
92
  }
93
+ export async function createEpic(fields, config = loadConfig()) {
94
+ return request(config, "POST", `/api/w/${config.slug}/epics`, fields);
95
+ }
86
96
  export async function listMessages(options = {}, config = loadConfig()) {
87
97
  const query = new URLSearchParams();
88
98
  if (options.ticketId)
package/dist/epics.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export function headingTitle(spec) {
3
+ const match = spec.match(/^#[ \t]+(.+?)\s*$/m);
4
+ const title = match?.[1]?.replace(/[ \t]+#+[ \t]*$/, "").trim();
5
+ return title || null;
6
+ }
7
+ export async function readEpicSpec(path, title) {
8
+ const spec_md = await readFile(path, "utf8");
9
+ const resolved = title?.trim() || headingTitle(spec_md);
10
+ if (!resolved)
11
+ throw new Error(`No # heading in ${path}. Pass --title TITLE.`);
12
+ return { title: resolved, spec_md };
13
+ }
14
+ export function epicProgressRows(epics, tickets) {
15
+ return epics.map((epic) => {
16
+ const linked = tickets.filter((ticket) => ticket.epic_id === epic.id);
17
+ return {
18
+ id: epic.id,
19
+ title: epic.title,
20
+ status: epic.status,
21
+ merged: linked.filter((ticket) => ticket.status === "merged").length,
22
+ total: linked.length,
23
+ };
24
+ });
25
+ }
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
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, listWorkspaces, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
4
+ import { answerDecision, createEpic, createTicket, createWorkspace, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
6
  import { loadConfig, writeHdConfig } from "./config.js";
7
+ import { epicProgressRows, readEpicSpec } from "./epics.js";
7
8
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
8
9
  function fail(message) {
9
10
  console.error(message);
@@ -96,7 +97,7 @@ async function cmdTicket(argv) {
96
97
  if (action === "new") {
97
98
  const { opts } = flags(rest);
98
99
  if (!opts.title)
99
- fail("usage: hd ticket new --title TITLE [--body TEXT] [--area TAG] [--provider NAME] [--epic ID]");
100
+ fail("usage: hd ticket new --title TITLE [--body TEXT] [--acceptance TEXT] [--area TAG] [--provider NAME] [--epic ID]");
100
101
  const { ticket } = await createTicket({
101
102
  title: opts.title,
102
103
  body_md: opts.body,
@@ -108,7 +109,41 @@ async function cmdTicket(argv) {
108
109
  console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)} ${ticket.title}`);
109
110
  return;
110
111
  }
111
- fail("usage: hd ticket list | show KEY | new --title TITLE");
112
+ if (action === "queue") {
113
+ const key = rest[0];
114
+ if (!key)
115
+ fail("usage: hd ticket queue KEY");
116
+ const { ticket } = await queueTicket(key.toUpperCase());
117
+ console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)}`);
118
+ return;
119
+ }
120
+ fail("usage: hd ticket list | show KEY | new --title TITLE | queue KEY");
121
+ }
122
+ async function cmdEpic(argv) {
123
+ const [action, ...rest] = argv;
124
+ if (action === "list") {
125
+ const [{ epics }, { tickets }] = await Promise.all([listEpics(), listTickets()]);
126
+ const rows = epicProgressRows(epics, tickets);
127
+ if (!rows.length) {
128
+ console.log(c.dim("No epics."));
129
+ return;
130
+ }
131
+ console.log(table(["KEY", "STATUS", "PROGRESS", "TITLE"], rows.map((epic) => [
132
+ epic.id, statusChip(epic.status), `${epic.merged}/${epic.total}`, epic.title,
133
+ ])));
134
+ return;
135
+ }
136
+ if (action === "new") {
137
+ const { rest: paths, opts } = flags(rest);
138
+ const path = paths.join(" ");
139
+ if (!path)
140
+ fail("usage: hd epic new PATH [--title TITLE]");
141
+ const input = await readEpicSpec(path, opts.title);
142
+ const { epic } = await createEpic(input);
143
+ console.log(`${c.bold(epic.id)} ${statusChip(epic.status)} ${epic.title}`);
144
+ return;
145
+ }
146
+ fail("usage: hd epic new PATH [--title TITLE] | list");
112
147
  }
113
148
  async function cmdLogs(argv) {
114
149
  const { rest, bools } = flags(argv);
@@ -264,6 +299,10 @@ export async function main(argv = process.argv.slice(2)) {
264
299
  await cmdTicket(rest);
265
300
  return;
266
301
  }
302
+ if (cmd === "epic") {
303
+ await cmdEpic(rest);
304
+ return;
305
+ }
267
306
  if (cmd === "logs") {
268
307
  await cmdLogs(rest);
269
308
  return;
package/dist/out.js CHANGED
@@ -64,7 +64,8 @@ export function usage() {
64
64
  return [
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
- ` ${c.blue("hd ticket list | show | new")} ticket operations`,
67
+ ` ${c.blue("hd ticket list | show | new | queue")} ticket operations`,
68
+ ` ${c.blue("hd epic new PATH | list")} epic operations`,
68
69
  ` ${c.blue("hd workspace ls | new")} list or create workspaces`,
69
70
  ` ${c.blue("hd agents [set]")} inspect or update agents`,
70
71
  ` ${c.blue("hd logs KEY [-f]")} run events`,
package/dist/tui/App.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
4
+ import { epicProgressRows } from "../epics.js";
4
5
  import { Banner } from "./Banner.js";
5
6
  import { Bubble } from "./Bubble.js";
6
7
  import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
@@ -14,7 +15,7 @@ import { alertOnce } from "./alert.js";
14
15
  import { bubbleRows } from "./height.js";
15
16
  import { planLayout, splitPanels } from "./layout.js";
16
17
  import { parseLine } from "./parse.js";
17
- import { configuredSlugs, decisionOptions, loadLiveEvents, loadTicketDetail, pollSnapshot, postOrchestrator, resolveDecision, switchWorkspace, updateAgent, updateProviderCap, } from "./data.js";
18
+ import { configuredSlugs, createEpicFromFile, decisionOptions, loadLiveEvents, loadTicketDetail, pollSnapshot, postOrchestrator, queueTicket, resolveDecision, switchWorkspace, updateAgent, updateProviderCap, } from "./data.js";
18
19
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
19
20
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
20
21
  import { UI } from "./theme.js";
@@ -311,6 +312,41 @@ export function App({ initial }) {
311
312
  setBusy(false);
312
313
  }
313
314
  return;
315
+ case "epic-new":
316
+ setBusy(true);
317
+ try {
318
+ const { epic } = await createEpicFromFile(config, action.path);
319
+ say("system", `Created epic ${epic.id}: ${epic.title}`);
320
+ await refresh();
321
+ }
322
+ catch (error) {
323
+ setNotice(error instanceof Error ? error.message : String(error));
324
+ }
325
+ finally {
326
+ setBusy(false);
327
+ }
328
+ return;
329
+ case "epics": {
330
+ const rows = epicProgressRows(board.epics, board.tickets);
331
+ say("system", rows.length
332
+ ? rows.map((epic) => `${epic.id} ${epic.status} ${epic.merged}/${epic.total} ${epic.title}`).join("\n")
333
+ : "No epics.");
334
+ return;
335
+ }
336
+ case "queue":
337
+ setBusy(true);
338
+ try {
339
+ const { ticket: queued } = await queueTicket(config, action.key);
340
+ say("system", `${queued.key} queued.`);
341
+ await refresh();
342
+ }
343
+ catch (error) {
344
+ setNotice(error instanceof Error ? error.message : String(error));
345
+ }
346
+ finally {
347
+ setBusy(false);
348
+ }
349
+ return;
314
350
  case "decide": {
315
351
  const decision = board.decisions[0];
316
352
  if (!decision) {
package/dist/tui/Help.js CHANGED
@@ -8,6 +8,9 @@ export const COMMANDS = [
8
8
  { name: "/board", help: "the kanban board" },
9
9
  { name: "/inbox", help: "decisions and messages waiting on you" },
10
10
  { name: "/ticket", args: "HD-12", help: "open one ticket" },
11
+ { name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
12
+ { name: "/epic", args: "new PATH", help: "create an epic from a Markdown spec" },
13
+ { name: "/epics", help: "list epics and ticket progress" },
11
14
  { name: "/decide", args: "2 | text", help: "answer the decision on screen" },
12
15
  { name: "/agents", help: "every agent in full, and the live run stream" },
13
16
  { name: "/settings", help: "change provider caps and agent settings" },
package/dist/tui/data.js CHANGED
@@ -1,5 +1,6 @@
1
- import { answerDecision, getStatus, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, showTicket, updateAgent as patchAgent, updateCaps, } from "../api.js";
1
+ import { answerDecision, createEpic as postEpic, getStatus, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, showTicket, updateAgent as patchAgent, updateCaps, } from "../api.js";
2
2
  import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
3
+ import { readEpicSpec } from "../epics.js";
3
4
  export const POLL_MS = 5_000;
4
5
  export const providers = ["claude", "codex", "gemini", "grok"];
5
6
  export const efforts = ["low", "medium", "high"];
@@ -98,6 +99,12 @@ export async function postOrchestrator(body, config) {
98
99
  export async function loadTicketDetail(config, key) {
99
100
  return showTicket(key, config);
100
101
  }
102
+ export async function createEpicFromFile(config, path) {
103
+ return postEpic(await readEpicSpec(path), config);
104
+ }
105
+ export async function queueTicket(config, key) {
106
+ return queueTicketNow(key, config);
107
+ }
101
108
  export async function waitForReply(config, since, timeoutMs) {
102
109
  const deadline = Date.now() + timeoutMs;
103
110
  while (Date.now() <= deadline) {
package/dist/tui/parse.js CHANGED
@@ -29,6 +29,16 @@ export function parseLine(raw) {
29
29
  return argument
30
30
  ? { kind: "ticket", key: argument.toUpperCase() }
31
31
  : { kind: "unknown", command: "ticket needs a key" };
32
+ case "epic":
33
+ return rest[0]?.toLowerCase() === "new" && rest.length > 1
34
+ ? { kind: "epic-new", path: rest.slice(1).join(" ") }
35
+ : { kind: "unknown", command: "epic needs new PATH" };
36
+ case "epics":
37
+ return { kind: "epics" };
38
+ case "queue":
39
+ return argument
40
+ ? { kind: "queue", key: argument.toUpperCase() }
41
+ : { kind: "unknown", command: "queue needs a key" };
32
42
  case "decide": {
33
43
  const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
34
44
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"