@higherdev/cli 0.10.0 → 0.11.1

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,7 +14,8 @@ 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
- "current": "workspace"
17
+ "current": "workspace",
18
+ "repos": {}
18
19
  }
19
20
  ```
20
21
 
@@ -35,7 +36,7 @@ workspace-map config shapes are migrated automatically when they are read.
35
36
  | `hd ticket cancel KEY` | Cancel a ticket |
36
37
  | `hd epic new PATH [--title TITLE]` | Create an epic from a Markdown spec |
37
38
  | `hd epic list` | List epics and ticket progress |
38
- | `hd plan [--repo DIR]` | Hand the terminal to Codex to author an epic spec |
39
+ | `hd plan [--repo DIR] [--clone-root DIR]` | Hand the terminal to Codex to author an epic spec |
39
40
  | `hd workspace ls` | List every workspace available to the configured key |
40
41
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Preflight GitHub, wire the runner, and create a paused workspace |
41
42
  | `hd workspace set [options]` | Update settings; max turns uses `--max-turns KIND=N[,KIND=N...]` |
@@ -56,9 +57,16 @@ workspace-map config shapes are migrated automatically when they are read.
56
57
 
57
58
  `hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
58
59
  branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` unless
59
- `--runner-user USER` overrides it.
60
+ `--runner-user USER` overrides it. On a terminal, missing name or repo flags start a guided wizard. If
61
+ the repository is missing, approve private creation interactively, use `--create` to force it, or
62
+ `--no-create` to fail. Fully flagged calls remain non-interactive for scripts and Mel.
63
+
64
+ `hd plan` remembers a matching checkout per workspace. If none exists, it offers to clone into
65
+ `~/HigherDEV/<slug>`; change the parent with `--clone-root DIR`. An explicit `--repo DIR` is saved
66
+ after its Git origin is verified.
60
67
 
61
68
  Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/cancel`, `/epic new`,
62
- `/epics`, `/plan`, `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`, `/feed`,
69
+ `/epics`, `/plan [--repo DIR]`, `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`,
70
+ `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/feed`,
63
71
  `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
64
72
  the HDX API every five seconds.
package/dist/config.js CHANGED
@@ -16,6 +16,11 @@ function normalizeConnection(value) {
16
16
  function missing(path) {
17
17
  return new Error(`missing ${path}\nWrite { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_...", "current": "workspace" }`);
18
18
  }
19
+ function normalizeRepos(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ return {};
22
+ return Object.fromEntries(Object.entries(value).filter((entry) => Boolean(entry[0]) && typeof entry[1] === "string" && Boolean(entry[1])));
23
+ }
19
24
  export function loadStoredConfig(path = configPath()) {
20
25
  let raw;
21
26
  try {
@@ -27,13 +32,14 @@ export function loadStoredConfig(path = configPath()) {
27
32
  const parsed = JSON.parse(raw);
28
33
  const connection = normalizeConnection(parsed);
29
34
  const current = typeof parsed.current === "string" ? parsed.current : "";
35
+ const repos = normalizeRepos(parsed.repos);
30
36
  if (connection && current)
31
- return { ...connection, current };
37
+ return { ...connection, current, repos };
32
38
  // 0.4 stored one workspace at the top level with `slug` rather than
33
39
  // `current`. One key can now reach every workspace, so only the selected
34
40
  // slug needs to survive.
35
41
  if (connection && typeof parsed.slug === "string" && parsed.slug) {
36
- const migrated = { ...connection, current: parsed.slug };
42
+ const migrated = { ...connection, current: parsed.slug, repos };
37
43
  writeStoredConfig(migrated, path);
38
44
  return migrated;
39
45
  }
@@ -46,7 +52,7 @@ export function loadStoredConfig(path = configPath()) {
46
52
  const selected = normalizeConnection(source[current]);
47
53
  if (!selected)
48
54
  throw new Error(`${path} current workspace ${current} is not configured`);
49
- const migrated = { ...selected, current };
55
+ const migrated = { ...selected, current, repos };
50
56
  writeStoredConfig(migrated, path);
51
57
  return migrated;
52
58
  }
@@ -62,10 +68,16 @@ export function writeStoredConfig(config, path = configPath()) {
62
68
  }
63
69
  /** Store one operator connection and make its selected workspace current. */
64
70
  export function writeHdConfig(config, path = configPath()) {
71
+ let repos = {};
72
+ try {
73
+ repos = loadStoredConfig(path).repos;
74
+ }
75
+ catch { }
65
76
  writeStoredConfig({
66
77
  url: config.url.replace(/\/$/, ""),
67
78
  api_key: config.api_key,
68
79
  current: config.slug,
80
+ repos,
69
81
  }, path);
70
82
  }
71
83
  export function switchWorkspace(slug, path = configPath()) {
@@ -73,3 +85,7 @@ export function switchWorkspace(slug, path = configPath()) {
73
85
  writeStoredConfig({ ...stored, current: slug }, path);
74
86
  return { url: stored.url, api_key: stored.api_key, slug };
75
87
  }
88
+ export function rememberWorkspaceRepo(slug, directory, path = configPath()) {
89
+ const stored = loadStoredConfig(path);
90
+ writeStoredConfig({ ...stored, repos: { ...stored.repos, [slug]: directory } }, path);
91
+ }
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);
@@ -157,11 +157,14 @@ async function cmdEpic(argv) {
157
157
  }
158
158
  async function cmdPlan(argv) {
159
159
  const { rest, opts, bools } = flags(argv);
160
- if (rest.length || bools.size || Object.keys(opts).some((key) => key !== "repo")) {
161
- fail("usage: hd plan [--repo DIR]");
160
+ if (rest.length || bools.size || Object.keys(opts).some((key) => !["repo", "clone-root"].includes(key))) {
161
+ fail("usage: hd plan [--repo DIR] [--clone-root DIR]");
162
162
  }
163
163
  const { workspace } = await getStatus();
164
- await launchArchitect({ workspaceRepo: workspace.repo, repo: opts.repo });
164
+ const result = await launchArchitect({ workspaceRepo: workspace.repo, workspaceSlug: workspace.slug,
165
+ repo: opts.repo, cloneRoot: opts["clone-root"] });
166
+ if (result.cloneCommand)
167
+ console.log(`Clone it with: ${result.cloneCommand}`);
165
168
  }
166
169
  async function cmdLogs(argv) {
167
170
  const { rest, bools } = flags(argv);
@@ -241,71 +244,27 @@ async function cmdWorkspace(argv, deps = {}) {
241
244
  ])));
242
245
  return;
243
246
  }
244
- const workspaceUsage = "usage: hd workspace ls | new ... | set [flags] | rotate-key";
245
247
  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}`);
248
+ if (rest.length)
249
+ fail(WORKSPACE_USAGE);
250
+ const result = await workspaceRotateKey();
251
+ console.log(`api key: ${result.apiKey}`);
250
252
  console.log("saved locally; update Mel's hd init configuration on the box.");
251
253
  return;
252
254
  }
253
255
  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);
256
+ const workspace = await workspaceSet(rest);
289
257
  console.log(`${workspace.slug} ${workspace.name} ${workspace.repo}`);
290
258
  return;
291
259
  }
292
260
  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 });
261
+ fail(WORKSPACE_USAGE);
262
+ const result = await workspaceNew(rest, deps);
263
+ if (result.invitationPending)
264
+ console.log(`invitation pending for ${result.runnerUser}`);
265
+ console.log(`workspace: ${result.slug}`);
307
266
  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}`);
267
+ console.log(result.initCommand);
309
268
  }
310
269
  function selectAgent(agents, target, provider) {
311
270
  const exact = agents.find((agent) => agent.id === target);
package/dist/out.js CHANGED
@@ -66,7 +66,7 @@ export function usage() {
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
67
  ` ${c.blue("hd ticket list | show | new | queue | cancel")} ticket operations`,
68
68
  ` ${c.blue("hd epic new PATH | list")} epic operations`,
69
- ` ${c.blue("hd plan [--repo DIR]")} author an epic with Codex`,
69
+ ` ${c.blue("hd plan [--repo DIR] [--clone-root DIR]")} author an epic with Codex`,
70
70
  ` ${c.blue("hd workspace ls | new | set | rotate-key")} workspace operations`,
71
71
  ` ${c.blue("hd agents [add | rm | set]")} manage agents`,
72
72
  ` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
package/dist/plan.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { isAbsolute, join, resolve } from "node:path";
5
- import { createInterface } from "node:readline/promises";
6
+ import { loadStoredConfig, rememberWorkspaceRepo } from "./config.js";
7
+ import { promptOnStdin } from "./prompt.js";
8
+ import { defaultGh } from "./workspace-preflight.js";
6
9
  const ARCHITECT_PROMPT = new URL("../prompts/architect.md", import.meta.url);
7
10
  function gitOutput(cwd, args) {
8
11
  const result = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -21,13 +24,13 @@ export function remoteRepo(remote) {
21
24
  return trimmed.includes("/") ? trimmed : null;
22
25
  }
23
26
  }
24
- function expanded(path) {
27
+ function expanded(path, home = homedir(), cwd = process.cwd()) {
25
28
  const value = path.trim();
26
29
  if (value === "~")
27
- return homedir();
30
+ return home;
28
31
  if (value.startsWith("~/"))
29
- return join(homedir(), value.slice(2));
30
- return isAbsolute(value) ? value : resolve(value);
32
+ return join(home, value.slice(2));
33
+ return isAbsolute(value) ? value : resolve(cwd, value);
31
34
  }
32
35
  export function matchingWorkspaceRepo(directory, workspaceRepo) {
33
36
  const root = gitOutput(expanded(directory), ["rev-parse", "--show-toplevel"]);
@@ -36,36 +39,6 @@ export function matchingWorkspaceRepo(directory, workspaceRepo) {
36
39
  const origin = gitOutput(root, ["remote", "get-url", "origin"]);
37
40
  return origin && remoteRepo(origin)?.toLowerCase() === workspaceRepo.toLowerCase() ? root : null;
38
41
  }
39
- async function askForRepo(workspaceRepo) {
40
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
41
- throw new Error(`Current directory is not ${workspaceRepo}. Pass --repo DIR.`);
42
- }
43
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
44
- try {
45
- return (await prompt.question(`Repository directory for ${workspaceRepo}: `)).trim();
46
- }
47
- finally {
48
- prompt.close();
49
- }
50
- }
51
- export async function resolveWorkspaceRepo(workspaceRepo, requested) {
52
- if (requested) {
53
- const match = matchingWorkspaceRepo(requested, workspaceRepo);
54
- if (!match)
55
- throw new Error(`${requested} is not a checkout of ${workspaceRepo}.`);
56
- return match;
57
- }
58
- const current = matchingWorkspaceRepo(process.cwd(), workspaceRepo);
59
- if (current)
60
- return current;
61
- const answer = await askForRepo(workspaceRepo);
62
- if (!answer)
63
- throw new Error("A repository directory is required.");
64
- const match = matchingWorkspaceRepo(answer, workspaceRepo);
65
- if (!match)
66
- throw new Error(`${answer} is not a checkout of ${workspaceRepo}.`);
67
- return match;
68
- }
69
42
  export function verifyCodex(checkVersion = () => spawnSync("codex", ["--version"], { stdio: "ignore" })) {
70
43
  const check = checkVersion();
71
44
  if (check.error || check.status !== 0) {
@@ -84,11 +57,62 @@ function runCodex(cwd, prompt) {
84
57
  });
85
58
  });
86
59
  }
60
+ function shellPath(path) {
61
+ return /^[A-Za-z0-9_./~-]+$/.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`;
62
+ }
63
+ export async function resolveWorkspaceRepo(options, runtime = {}) {
64
+ const matchRepo = runtime.matchRepo ?? matchingWorkspaceRepo;
65
+ const cwd = runtime.cwd?.() ?? process.cwd();
66
+ const home = runtime.home?.() ?? homedir();
67
+ const saveRepo = runtime.saveRepo ?? rememberWorkspaceRepo;
68
+ const accept = (directory) => {
69
+ saveRepo(options.workspaceSlug, directory);
70
+ return { directory };
71
+ };
72
+ if (options.repo) {
73
+ const match = matchRepo(options.repo, options.workspaceRepo);
74
+ if (!match)
75
+ throw new Error(`${options.repo} is not a checkout of ${options.workspaceRepo}.`);
76
+ return accept(match);
77
+ }
78
+ const saved = (runtime.savedRepo ?? ((slug) => loadStoredConfig().repos[slug]))(options.workspaceSlug);
79
+ if (saved) {
80
+ const match = matchRepo(saved, options.workspaceRepo);
81
+ if (match)
82
+ return { directory: match };
83
+ }
84
+ const current = matchRepo(cwd, options.workspaceRepo);
85
+ if (current)
86
+ return accept(current);
87
+ const defaultRoot = "~/HigherDEV";
88
+ const rootInput = options.cloneRoot ?? defaultRoot;
89
+ const root = expanded(rootInput, home, cwd);
90
+ const destination = join(root, options.workspaceSlug);
91
+ const shownDestination = join(rootInput, options.workspaceSlug);
92
+ const cloneCommand = `gh repo clone ${options.workspaceRepo} ${shellPath(shownDestination)}`;
93
+ const interactive = runtime.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
94
+ if (!interactive || ["n", "no"].includes((await (runtime.prompt ?? promptOnStdin)(`Clone ${options.workspaceRepo} to ${shownDestination}? [Y/n] `)).trim().toLowerCase()))
95
+ return { cloneCommand };
96
+ const gh = runtime.gh ?? defaultGh;
97
+ try {
98
+ await gh(["--version"]);
99
+ }
100
+ catch {
101
+ throw new Error("GitHub CLI is unavailable. Install it if needed, then run `gh auth login`.");
102
+ }
103
+ (runtime.mkdir ?? ((path) => mkdirSync(path, { recursive: true })))(root);
104
+ await gh(["repo", "clone", options.workspaceRepo, destination]);
105
+ const cloned = matchRepo(destination, options.workspaceRepo);
106
+ if (!cloned)
107
+ throw new Error(`Clone completed but ${destination} is not a checkout of ${options.workspaceRepo}.`);
108
+ return accept(cloned);
109
+ }
87
110
  export async function launchArchitect(options, runtime = {}) {
111
+ const resolved = await resolveWorkspaceRepo(options, runtime);
112
+ if ("cloneCommand" in resolved)
113
+ return { launched: false, cloneCommand: resolved.cloneCommand };
88
114
  (runtime.verifyCodex ?? verifyCodex)();
89
- const [cwd, prompt] = await Promise.all([
90
- resolveWorkspaceRepo(options.workspaceRepo, options.repo),
91
- readFile(ARCHITECT_PROMPT, "utf8"),
92
- ]);
93
- await (runtime.invokeCodex ?? runCodex)(cwd, prompt);
115
+ const prompt = await readFile(ARCHITECT_PROMPT, "utf8");
116
+ await (runtime.invokeCodex ?? runCodex)(resolved.directory, prompt);
117
+ return { launched: true };
94
118
  }
package/dist/prompt.js ADDED
@@ -0,0 +1,13 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ export async function promptOnStdin(question, resumeStdin = false) {
3
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
4
+ try {
5
+ return await prompt.question(question);
6
+ }
7
+ finally {
8
+ prompt.close();
9
+ // Ink restores raw mode and listeners, but it cannot read a paused stream.
10
+ if (resumeStdin)
11
+ process.stdin.resume();
12
+ }
13
+ }
package/dist/tui/App.js CHANGED
@@ -1,8 +1,11 @@
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 { promptOnStdin } from "../prompt.js";
8
+ import { workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
6
9
  import { Banner } from "./Banner.js";
7
10
  import { Bubble } from "./Bubble.js";
8
11
  import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
@@ -23,6 +26,7 @@ import { UI } from "./theme.js";
23
26
  import { WorkspaceLoads } from "./workspace-load.js";
24
27
  let messageSeq = 0;
25
28
  const nextId = () => `m${messageSeq++}`;
29
+ const tuiPrompt = (question) => promptOnStdin(question, true);
26
30
  export function App({ initial }) {
27
31
  const { exit, suspendTerminal } = useApp();
28
32
  const { stdout } = useStdout();
@@ -165,10 +169,10 @@ export function App({ initial }) {
165
169
  setBusy(false);
166
170
  }
167
171
  }, [settings, config, refresh]);
168
- const changeWorkspace = useCallback(async (slug) => {
172
+ const changeWorkspace = useCallback(async (slug, source = config) => {
169
173
  setBusy(true);
170
174
  try {
171
- const snapshot = await switchWorkspace(slug, config);
175
+ const snapshot = await switchWorkspace(slug, source);
172
176
  loads.current.switchTo(snapshot.workspace.id);
173
177
  setConfig(snapshot.config);
174
178
  setWorkspace(snapshot.workspace);
@@ -188,7 +192,7 @@ export function App({ initial }) {
188
192
  finally {
189
193
  setBusy(false);
190
194
  }
191
- }, [say]);
195
+ }, [config, say]);
192
196
  const askOrchestrator = useCallback(async (text) => {
193
197
  setBusy(true);
194
198
  const id = nextId();
@@ -298,6 +302,35 @@ export function App({ initial }) {
298
302
  }
299
303
  }
300
304
  return;
305
+ case "workspace-command":
306
+ setBusy(true);
307
+ try {
308
+ if (action.command === "new") {
309
+ let created;
310
+ await suspendTerminal(async () => { created = await workspaceNew(action.args, { prompt: tuiPrompt }); });
311
+ if (!created)
312
+ throw new Error("Workspace wizard did not finish.");
313
+ await changeWorkspace(created.slug, loadConfig());
314
+ say("system", `Created ${created.slug}. Configure the box with: ${created.initCommand}`);
315
+ }
316
+ else if (action.command === "set") {
317
+ const updated = await workspaceSet(action.args);
318
+ say("system", `Updated ${updated.slug}: ${updated.name} (${updated.repo}).`);
319
+ await refresh();
320
+ }
321
+ else {
322
+ const rotated = await workspaceRotateKey();
323
+ setConfig(loadConfig());
324
+ say("system", `API key: ${rotated.apiKey}\nSaved locally. Update Mel's hd init configuration on the box.`);
325
+ }
326
+ }
327
+ catch (error) {
328
+ setNotice(error instanceof Error ? error.message : String(error));
329
+ }
330
+ finally {
331
+ setBusy(false);
332
+ }
333
+ return;
301
334
  case "ticket":
302
335
  setTicketKey(action.key);
303
336
  setView("ticket");
@@ -403,9 +436,21 @@ export function App({ initial }) {
403
436
  case "plan":
404
437
  setBusy(true);
405
438
  try {
406
- await suspendTerminal(() => launchArchitect({ workspaceRepo: workspace.repo }));
407
- say("system", "Architect session ended. Review the spec, then run /epic new PATH.");
408
- await refresh();
439
+ let result;
440
+ await suspendTerminal(async () => {
441
+ result = await launchArchitect({
442
+ workspaceRepo: workspace.repo, workspaceSlug: workspace.slug,
443
+ repo: action.repo, cloneRoot: action.cloneRoot,
444
+ }, { prompt: tuiPrompt });
445
+ });
446
+ if (!result)
447
+ throw new Error("Architect did not finish.");
448
+ if (result.cloneCommand)
449
+ say("system", `Clone it with: ${result.cloneCommand}`);
450
+ else {
451
+ say("system", "Architect session ended. Review the spec, then run /epic new PATH.");
452
+ await refresh();
453
+ }
409
454
  }
410
455
  catch (error) {
411
456
  setNotice(error instanceof Error ? error.message : String(error));
package/dist/tui/Help.js CHANGED
@@ -12,11 +12,11 @@ export const COMMANDS = [
12
12
  { name: "/cancel", args: "HD-12", help: "cancel a ticket" },
13
13
  { name: "/epic", args: "new PATH", help: "create an epic from a Markdown spec" },
14
14
  { name: "/epics", help: "list epics and ticket progress" },
15
- { name: "/plan", help: "author an epic with your Codex CLI" },
15
+ { name: "/plan", args: "[--repo DIR]", help: "author an epic with your Codex CLI" },
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":
@@ -56,8 +60,20 @@ export function parseLine(raw) {
56
60
  case "cancel":
57
61
  return argument ? { kind: "cancel", key: argument.toUpperCase() }
58
62
  : { kind: "unknown", command: "cancel needs a key" };
59
- case "plan":
60
- return argument ? { kind: "unknown", command: "plan takes no arguments" } : { kind: "plan" };
63
+ case "plan": {
64
+ const opts = {};
65
+ for (let i = 0; i < rest.length; i += 2) {
66
+ if (!rest[i]?.startsWith("--") || !rest[i + 1]) {
67
+ return { kind: "unknown", command: "plan accepts --repo DIR and --clone-root DIR" };
68
+ }
69
+ opts[rest[i].slice(2)] = rest[i + 1];
70
+ }
71
+ if (Object.keys(opts).some((key) => !["repo", "clone-root"].includes(key))) {
72
+ return { kind: "unknown", command: "plan accepts --repo DIR and --clone-root DIR" };
73
+ }
74
+ return { kind: "plan", ...(opts.repo ? { repo: opts.repo } : {}),
75
+ ...(opts["clone-root"] ? { cloneRoot: opts["clone-root"] } : {}) };
76
+ }
61
77
  case "decide": {
62
78
  const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
63
79
  return {
@@ -0,0 +1,162 @@
1
+ import { createWorkspace, rotateWorkspaceApiKey, updateWorkspace } from "./api.js";
2
+ import { loadConfig, writeHdConfig } from "./config.js";
3
+ import { promptOnStdin } from "./prompt.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
+ return deps.prompt ?? promptOnStdin;
48
+ }
49
+ async function askDefault(ask, label, fallback = "") {
50
+ const answer = (await ask(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim();
51
+ return answer || fallback;
52
+ }
53
+ export async function workspaceNew(argv, deps = {}) {
54
+ const { opts, bools, rest } = flags(argv);
55
+ if (rest.length || (bools.has("create") && bools.has("no-create")))
56
+ throw new Error(WORKSPACE_USAGE);
57
+ const interactive = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
58
+ const guided = !opts.name || !opts.repo;
59
+ if (guided && !interactive)
60
+ throw new Error(WORKSPACE_USAGE);
61
+ const gh = deps.gh ?? defaultGh;
62
+ const prompt = lazyPrompt(deps);
63
+ {
64
+ let name = opts.name ?? "";
65
+ let repo = opts.repo ?? "";
66
+ let branch = opts.branch;
67
+ let host = opts.host ?? "box";
68
+ if (guided || interactive || bools.has("create") || bools.has("no-create")) {
69
+ try {
70
+ await gh(["--version"]);
71
+ }
72
+ catch {
73
+ throw new Error("GitHub CLI is unavailable. Install it if needed, then run `gh auth login`.");
74
+ }
75
+ }
76
+ if (guided) {
77
+ name = await askDefault(prompt, "Name", name);
78
+ if (!name)
79
+ throw new Error("Name is required.");
80
+ let defaultRepo = repo;
81
+ if (!defaultRepo) {
82
+ const owner = (await gh(["api", "user", "--jq", ".login"])).trim();
83
+ defaultRepo = `${owner}/${workspaceSlug(name)}`;
84
+ }
85
+ repo = await askDefault(prompt, "Repo", defaultRepo);
86
+ if (!repo)
87
+ throw new Error("Repo is required.");
88
+ }
89
+ let state;
90
+ if (guided || interactive || bools.has("create") || bools.has("no-create"))
91
+ state = await repoState(repo, gh);
92
+ if (state && !state.found) {
93
+ if (bools.has("no-create"))
94
+ throw new Error(`GitHub repository ${repo} does not exist and --no-create was set.`);
95
+ const create = bools.has("create") || (interactive && yes(await prompt(`Create ${repo} as a private GitHub repo? [Y/n] `)));
96
+ if (!create)
97
+ throw new Error(`GitHub repository ${repo} was not created.`);
98
+ await gh(["repo", "create", repo, "--private"]);
99
+ state = { found: true, branch: "main" };
100
+ }
101
+ if (guided) {
102
+ branch = await askDefault(prompt, "Branch", branch ?? state?.branch ?? "main");
103
+ host = await askDefault(prompt, "Host", host);
104
+ const summary = `${name} | ${repo} | ${branch} | host ${host}`;
105
+ if (!yes(await prompt(`Create ${summary}. Proceed? [Y/n] `)))
106
+ throw new Error("Workspace creation cancelled.");
107
+ }
108
+ const preflight = await (deps.preflightWorkspace ?? preflightWorkspace)({
109
+ name, repo, branch, noBootstrap: bools.has("no-bootstrap"),
110
+ runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER,
111
+ }, gh);
112
+ const result = await createWorkspace({ name, repo, slug: opts.slug,
113
+ default_branch: preflight.branch, default_host: host });
114
+ writeHdConfig({ url: result.url, api_key: result.api_key, slug: result.workspace.slug });
115
+ return { slug: result.workspace.slug, repo: result.workspace.repo,
116
+ invitationPending: preflight.invitationPending,
117
+ runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER,
118
+ initCommand: `hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}` };
119
+ }
120
+ }
121
+ export async function workspaceSet(argv) {
122
+ const { opts, bools, rest } = flags(argv);
123
+ if (rest.length || bools.size)
124
+ throw new Error(WORKSPACE_USAGE);
125
+ const fields = {};
126
+ for (const [flag, field] of [["name", "name"], ["repo", "repo"], ["branch", "default_branch"], ["host", "default_host"]]) {
127
+ if (opts[flag])
128
+ fields[field] = opts[flag];
129
+ }
130
+ if (opts["auto-merge"]) {
131
+ if (!["on", "off"].includes(opts["auto-merge"]))
132
+ throw new Error("--auto-merge must be on or off");
133
+ fields.auto_merge = opts["auto-merge"] === "on";
134
+ }
135
+ if (opts["max-turns"]) {
136
+ const turns = {};
137
+ for (const entry of opts["max-turns"].split(",")) {
138
+ const [kind, raw, ...extra] = entry.split("=");
139
+ const value = Number(raw);
140
+ if (extra.length || !["build", "followup", "review", "orchestrate"].includes(kind)
141
+ || !Number.isInteger(value) || value < 1)
142
+ throw new Error("--max-turns must be KIND=N[,KIND=N...]");
143
+ turns[kind] = value;
144
+ }
145
+ fields.max_turns = turns;
146
+ }
147
+ if (opts["max-attempts"]) {
148
+ const value = Number(opts["max-attempts"]);
149
+ if (!Number.isInteger(value) || value < 1)
150
+ throw new Error("--max-attempts must be an integer >= 1");
151
+ fields.max_attempts = value;
152
+ }
153
+ if (!Object.keys(fields).length)
154
+ throw new Error(WORKSPACE_USAGE);
155
+ return (await updateWorkspace(fields)).workspace;
156
+ }
157
+ export async function workspaceRotateKey() {
158
+ const config = loadConfig();
159
+ const { api_key } = await rotateWorkspaceApiKey(config);
160
+ writeHdConfig({ ...config, api_key });
161
+ return { apiKey: api_key, slug: config.slug };
162
+ }
@@ -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.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "build": "tsc",
15
15
  "test": "pnpm build && node --experimental-strip-types --test test/*.test.ts",
16
+ "test:pty": "pnpm build && expect test/tui-prompt.expect",
16
17
  "prepublishOnly": "npm run build"
17
18
  },
18
19
  "dependencies": {