@higherdev/cli 0.10.0 → 0.11.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
@@ -56,9 +56,12 @@ workspace-map config shapes are migrated automatically when they are read.
56
56
 
57
57
  `hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
58
58
  branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` unless
59
- `--runner-user USER` overrides it.
59
+ `--runner-user USER` overrides it. On a terminal, missing name or repo flags start a guided wizard. If
60
+ the repository is missing, approve private creation interactively, use `--create` to force it, or
61
+ `--no-create` to fail. Fully flagged calls remain non-interactive for scripts and Mel.
60
62
 
61
63
  Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/cancel`, `/epic new`,
62
- `/epics`, `/plan`, `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`, `/feed`,
64
+ `/epics`, `/plan`, `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`,
65
+ `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/feed`,
63
66
  `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
64
67
  the HDX API every five seconds.
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
- import { answerDecision, cancelTicket, createAgent, createEpic, createTicket, createWorkspace, deleteAgent, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, rotateWorkspaceApiKey, setPaused, showTicket, updateAgent, updateCaps, updateWorkspace, } from "./api.js";
4
+ import { answerDecision, cancelTicket, createAgent, createEpic, createTicket, deleteAgent, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, updateCaps, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
- import { loadConfig, writeHdConfig } from "./config.js";
6
+ import { loadConfig } from "./config.js";
7
7
  import { epicProgressRows, readEpicSpec } from "./epics.js";
8
8
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
9
9
  import { launchArchitect } from "./plan.js";
10
- import { HDX_RUNNER_GH_USER, preflightWorkspace } from "./workspace-preflight.js";
10
+ import { WORKSPACE_USAGE, workspaceNew, workspaceRotateKey, workspaceSet } from "./workspace-commands.js";
11
11
  function fail(message) {
12
12
  console.error(message);
13
13
  process.exit(1);
@@ -241,71 +241,27 @@ async function cmdWorkspace(argv, deps = {}) {
241
241
  ])));
242
242
  return;
243
243
  }
244
- const workspaceUsage = "usage: hd workspace ls | new ... | set [flags] | rotate-key";
245
244
  if (action === "rotate-key") {
246
- const config = loadConfig();
247
- const { api_key } = await rotateWorkspaceApiKey(config);
248
- writeHdConfig({ ...config, api_key });
249
- console.log(`api key: ${api_key}`);
245
+ if (rest.length)
246
+ fail(WORKSPACE_USAGE);
247
+ const result = await workspaceRotateKey();
248
+ console.log(`api key: ${result.apiKey}`);
250
249
  console.log("saved locally; update Mel's hd init configuration on the box.");
251
250
  return;
252
251
  }
253
252
  if (action === "set") {
254
- const { rest: args, opts, bools } = flags(rest);
255
- if (args.length || bools.size)
256
- fail(workspaceUsage);
257
- const fields = {};
258
- for (const [flag, field] of [["name", "name"], ["repo", "repo"], ["branch", "default_branch"], ["host", "default_host"]]) {
259
- if (opts[flag])
260
- fields[field] = opts[flag];
261
- }
262
- if (opts["auto-merge"]) {
263
- if (!["on", "off"].includes(opts["auto-merge"]))
264
- fail("--auto-merge must be on or off");
265
- fields.auto_merge = opts["auto-merge"] === "on";
266
- }
267
- if (opts["max-turns"]) {
268
- const maxTurns = {};
269
- for (const entry of opts["max-turns"].split(",")) {
270
- const [kind, raw, ...extra] = entry.split("=");
271
- const value = Number(raw);
272
- if (extra.length || !["build", "followup", "review", "orchestrate"].includes(kind)
273
- || !Number.isInteger(value) || value < 1) {
274
- fail("--max-turns must be KIND=N[,KIND=N...] for build, followup, review, or orchestrate");
275
- }
276
- maxTurns[kind] = value;
277
- }
278
- fields.max_turns = maxTurns;
279
- }
280
- if (opts["max-attempts"]) {
281
- const value = Number(opts["max-attempts"]);
282
- if (!Number.isInteger(value) || value < 1)
283
- fail("--max-attempts must be an integer >= 1");
284
- fields.max_attempts = value;
285
- }
286
- if (!Object.keys(fields).length)
287
- fail(workspaceUsage);
288
- const { workspace } = await updateWorkspace(fields);
253
+ const workspace = await workspaceSet(rest);
289
254
  console.log(`${workspace.slug} ${workspace.name} ${workspace.repo}`);
290
255
  return;
291
256
  }
292
257
  if (action !== "new")
293
- fail(workspaceUsage);
294
- const { opts, bools } = flags(rest);
295
- if (!opts.name || !opts.repo) {
296
- fail(workspaceUsage);
297
- }
298
- const preflight = await (deps.preflightWorkspace ?? preflightWorkspace)({ name: opts.name, repo: opts.repo,
299
- branch: opts.branch, noBootstrap: bools.has("no-bootstrap"),
300
- runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER });
301
- if (preflight.invitationPending)
302
- console.log(`invitation pending for ${opts["runner-user"] ?? HDX_RUNNER_GH_USER}`);
303
- const result = await createWorkspace({ name: opts.name, repo: opts.repo, slug: opts.slug,
304
- default_branch: preflight.branch, default_host: opts.host ?? "box" });
305
- console.log(`workspace: ${result.workspace.slug}`);
306
- writeHdConfig({ url: result.url, api_key: result.api_key, slug: result.workspace.slug });
258
+ fail(WORKSPACE_USAGE);
259
+ const result = await workspaceNew(rest, deps);
260
+ if (result.invitationPending)
261
+ console.log(`invitation pending for ${result.runnerUser}`);
262
+ console.log(`workspace: ${result.slug}`);
307
263
  console.log("saved and switched; configure another machine with:");
308
- console.log(`hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}`);
264
+ console.log(result.initCommand);
309
265
  }
310
266
  function selectAgent(agents, target, provider) {
311
267
  const exact = agents.find((agent) => agent.id === target);
package/dist/tui/App.js CHANGED
@@ -1,8 +1,10 @@
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 { loadConfig } from "../config.js";
4
5
  import { epicProgressRows } from "../epics.js";
5
6
  import { launchArchitect } from "../plan.js";
7
+ import { workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
6
8
  import { Banner } from "./Banner.js";
7
9
  import { Bubble } from "./Bubble.js";
8
10
  import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
@@ -165,10 +167,10 @@ export function App({ initial }) {
165
167
  setBusy(false);
166
168
  }
167
169
  }, [settings, config, refresh]);
168
- const changeWorkspace = useCallback(async (slug) => {
170
+ const changeWorkspace = useCallback(async (slug, source = config) => {
169
171
  setBusy(true);
170
172
  try {
171
- const snapshot = await switchWorkspace(slug, config);
173
+ const snapshot = await switchWorkspace(slug, source);
172
174
  loads.current.switchTo(snapshot.workspace.id);
173
175
  setConfig(snapshot.config);
174
176
  setWorkspace(snapshot.workspace);
@@ -188,7 +190,7 @@ export function App({ initial }) {
188
190
  finally {
189
191
  setBusy(false);
190
192
  }
191
- }, [say]);
193
+ }, [config, say]);
192
194
  const askOrchestrator = useCallback(async (text) => {
193
195
  setBusy(true);
194
196
  const id = nextId();
@@ -298,6 +300,35 @@ export function App({ initial }) {
298
300
  }
299
301
  }
300
302
  return;
303
+ case "workspace-command":
304
+ setBusy(true);
305
+ try {
306
+ if (action.command === "new") {
307
+ let created;
308
+ await suspendTerminal(async () => { created = await workspaceNew(action.args); });
309
+ if (!created)
310
+ throw new Error("Workspace wizard did not finish.");
311
+ await changeWorkspace(created.slug, loadConfig());
312
+ say("system", `Created ${created.slug}. Configure the box with: ${created.initCommand}`);
313
+ }
314
+ else if (action.command === "set") {
315
+ const updated = await workspaceSet(action.args);
316
+ say("system", `Updated ${updated.slug}: ${updated.name} (${updated.repo}).`);
317
+ await refresh();
318
+ }
319
+ else {
320
+ const rotated = await workspaceRotateKey();
321
+ setConfig(loadConfig());
322
+ say("system", `API key: ${rotated.apiKey}\nSaved locally. Update Mel's hd init configuration on the box.`);
323
+ }
324
+ }
325
+ catch (error) {
326
+ setNotice(error instanceof Error ? error.message : String(error));
327
+ }
328
+ finally {
329
+ setBusy(false);
330
+ }
331
+ return;
301
332
  case "ticket":
302
333
  setTicketKey(action.key);
303
334
  setView("ticket");
package/dist/tui/Help.js CHANGED
@@ -16,7 +16,7 @@ export const COMMANDS = [
16
16
  { name: "/decide", args: "2 | text", help: "answer the decision on screen" },
17
17
  { name: "/agents", args: "[add ... | rm ROLE|ID]", help: "view or manage agents" },
18
18
  { name: "/settings", help: "change provider caps and agent settings" },
19
- { name: "/workspace", args: "[slug]", help: "switch workspace, or list the ones you can reach" },
19
+ { name: "/workspace", args: "[slug | new | set | rotate-key]", help: "list, switch, create, or configure" },
20
20
  { name: "/feed", help: "what just happened" },
21
21
  { name: "/orchestrator", help: "talk to the agent that gets work in flight finished" },
22
22
  { name: "/refresh", help: "reload the board now" },
package/dist/tui/parse.js CHANGED
@@ -37,6 +37,10 @@ export function parseLine(raw) {
37
37
  return { kind: "help" };
38
38
  case "workspace":
39
39
  case "ws": {
40
+ const command = rest[0]?.toLowerCase();
41
+ if (command && ["new", "set", "rotate-key"].includes(command)) {
42
+ return { kind: "workspace-command", command: command, args: rest.slice(1) };
43
+ }
40
44
  return { kind: "workspace", slug: argument || null };
41
45
  }
42
46
  case "ticket":
@@ -0,0 +1,170 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { createWorkspace, rotateWorkspaceApiKey, updateWorkspace } from "./api.js";
3
+ import { loadConfig, writeHdConfig } from "./config.js";
4
+ import { defaultGh, HDX_RUNNER_GH_USER, preflightWorkspace } from "./workspace-preflight.js";
5
+ export const WORKSPACE_USAGE = "usage: hd workspace ls | new --name NAME --repo OWNER/NAME [--create|--no-create] [flags] | set [flags] | rotate-key";
6
+ function flags(argv) {
7
+ const opts = {};
8
+ const bools = new Set();
9
+ const rest = [];
10
+ for (let i = 0; i < argv.length; i += 1) {
11
+ const arg = argv[i];
12
+ if (!arg.startsWith("--")) {
13
+ rest.push(arg);
14
+ continue;
15
+ }
16
+ const next = argv[i + 1];
17
+ if (next && !next.startsWith("-")) {
18
+ opts[arg.slice(2)] = next;
19
+ i += 1;
20
+ }
21
+ else
22
+ bools.add(arg.slice(2));
23
+ }
24
+ return { opts, bools, rest };
25
+ }
26
+ export function workspaceSlug(name) {
27
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
28
+ }
29
+ function yes(answer) {
30
+ return !["n", "no"].includes(answer.trim().toLowerCase());
31
+ }
32
+ function message(error) {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+ async function repoState(repo, gh) {
36
+ try {
37
+ const value = JSON.parse(await gh(["repo", "view", repo, "--json", "defaultBranchRef"]));
38
+ return { found: true, branch: value.defaultBranchRef?.name };
39
+ }
40
+ catch (error) {
41
+ if (/404|not found|could not resolve/i.test(message(error)))
42
+ return { found: false };
43
+ throw error;
44
+ }
45
+ }
46
+ function lazyPrompt(deps) {
47
+ let readline;
48
+ const ask = deps.prompt ?? (async (question) => {
49
+ readline ??= createInterface({ input: process.stdin, output: process.stdout });
50
+ return readline.question(question);
51
+ });
52
+ return { ask, close: () => readline?.close() };
53
+ }
54
+ async function askDefault(ask, label, fallback = "") {
55
+ const answer = (await ask(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim();
56
+ return answer || fallback;
57
+ }
58
+ export async function workspaceNew(argv, deps = {}) {
59
+ const { opts, bools, rest } = flags(argv);
60
+ if (rest.length || (bools.has("create") && bools.has("no-create")))
61
+ throw new Error(WORKSPACE_USAGE);
62
+ const interactive = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
63
+ const guided = !opts.name || !opts.repo;
64
+ if (guided && !interactive)
65
+ throw new Error(WORKSPACE_USAGE);
66
+ const gh = deps.gh ?? defaultGh;
67
+ const prompt = lazyPrompt(deps);
68
+ try {
69
+ let name = opts.name ?? "";
70
+ let repo = opts.repo ?? "";
71
+ let branch = opts.branch;
72
+ let host = opts.host ?? "box";
73
+ if (guided || interactive || bools.has("create") || bools.has("no-create")) {
74
+ try {
75
+ await gh(["--version"]);
76
+ }
77
+ catch {
78
+ throw new Error("GitHub CLI is unavailable. Install it if needed, then run `gh auth login`.");
79
+ }
80
+ }
81
+ if (guided) {
82
+ name = await askDefault(prompt.ask, "Name", name);
83
+ if (!name)
84
+ throw new Error("Name is required.");
85
+ let defaultRepo = repo;
86
+ if (!defaultRepo) {
87
+ const owner = (await gh(["api", "user", "--jq", ".login"])).trim();
88
+ defaultRepo = `${owner}/${workspaceSlug(name)}`;
89
+ }
90
+ repo = await askDefault(prompt.ask, "Repo", defaultRepo);
91
+ if (!repo)
92
+ throw new Error("Repo is required.");
93
+ }
94
+ let state;
95
+ if (guided || interactive || bools.has("create") || bools.has("no-create"))
96
+ state = await repoState(repo, gh);
97
+ if (state && !state.found) {
98
+ if (bools.has("no-create"))
99
+ throw new Error(`GitHub repository ${repo} does not exist and --no-create was set.`);
100
+ const create = bools.has("create") || (interactive && yes(await prompt.ask(`Create ${repo} as a private GitHub repo? [Y/n] `)));
101
+ if (!create)
102
+ throw new Error(`GitHub repository ${repo} was not created.`);
103
+ await gh(["repo", "create", repo, "--private"]);
104
+ state = { found: true, branch: "main" };
105
+ }
106
+ if (guided) {
107
+ branch = await askDefault(prompt.ask, "Branch", branch ?? state?.branch ?? "main");
108
+ host = await askDefault(prompt.ask, "Host", host);
109
+ const summary = `${name} | ${repo} | ${branch} | host ${host}`;
110
+ if (!yes(await prompt.ask(`Create ${summary}. Proceed? [Y/n] `)))
111
+ throw new Error("Workspace creation cancelled.");
112
+ }
113
+ const preflight = await (deps.preflightWorkspace ?? preflightWorkspace)({
114
+ name, repo, branch, noBootstrap: bools.has("no-bootstrap"),
115
+ runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER,
116
+ }, gh);
117
+ const result = await createWorkspace({ name, repo, slug: opts.slug,
118
+ default_branch: preflight.branch, default_host: host });
119
+ writeHdConfig({ url: result.url, api_key: result.api_key, slug: result.workspace.slug });
120
+ return { slug: result.workspace.slug, repo: result.workspace.repo,
121
+ invitationPending: preflight.invitationPending,
122
+ runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER,
123
+ initCommand: `hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}` };
124
+ }
125
+ finally {
126
+ prompt.close();
127
+ }
128
+ }
129
+ export async function workspaceSet(argv) {
130
+ const { opts, bools, rest } = flags(argv);
131
+ if (rest.length || bools.size)
132
+ throw new Error(WORKSPACE_USAGE);
133
+ const fields = {};
134
+ for (const [flag, field] of [["name", "name"], ["repo", "repo"], ["branch", "default_branch"], ["host", "default_host"]]) {
135
+ if (opts[flag])
136
+ fields[field] = opts[flag];
137
+ }
138
+ if (opts["auto-merge"]) {
139
+ if (!["on", "off"].includes(opts["auto-merge"]))
140
+ throw new Error("--auto-merge must be on or off");
141
+ fields.auto_merge = opts["auto-merge"] === "on";
142
+ }
143
+ if (opts["max-turns"]) {
144
+ const turns = {};
145
+ for (const entry of opts["max-turns"].split(",")) {
146
+ const [kind, raw, ...extra] = entry.split("=");
147
+ const value = Number(raw);
148
+ if (extra.length || !["build", "followup", "review", "orchestrate"].includes(kind)
149
+ || !Number.isInteger(value) || value < 1)
150
+ throw new Error("--max-turns must be KIND=N[,KIND=N...]");
151
+ turns[kind] = value;
152
+ }
153
+ fields.max_turns = turns;
154
+ }
155
+ if (opts["max-attempts"]) {
156
+ const value = Number(opts["max-attempts"]);
157
+ if (!Number.isInteger(value) || value < 1)
158
+ throw new Error("--max-attempts must be an integer >= 1");
159
+ fields.max_attempts = value;
160
+ }
161
+ if (!Object.keys(fields).length)
162
+ throw new Error(WORKSPACE_USAGE);
163
+ return (await updateWorkspace(fields)).workspace;
164
+ }
165
+ export async function workspaceRotateKey() {
166
+ const config = loadConfig();
167
+ const { api_key } = await rotateWorkspaceApiKey(config);
168
+ writeHdConfig({ ...config, api_key });
169
+ return { apiKey: api_key, slug: config.slug };
170
+ }
@@ -19,7 +19,7 @@ function errorMessage(error) {
19
19
  const value = error;
20
20
  return value.stderr?.trim() || value.message || String(error);
21
21
  }
22
- async function defaultGh(args) {
22
+ export async function defaultGh(args) {
23
23
  try {
24
24
  return (await exec("gh", args, { encoding: "utf8", maxBuffer: 1024 * 1024 })).stdout;
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"