@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 +12 -4
- package/dist/config.js +19 -3
- package/dist/index.js +20 -61
- package/dist/out.js +1 -1
- package/dist/plan.js +64 -40
- package/dist/prompt.js +13 -0
- package/dist/tui/App.js +51 -6
- package/dist/tui/Help.js +2 -2
- package/dist/tui/parse.js +18 -2
- package/dist/workspace-commands.js +162 -0
- package/dist/workspace-preflight.js +1 -1
- package/package.json +2 -1
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`,
|
|
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,
|
|
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
|
|
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 {
|
|
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) =>
|
|
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,
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
console.log(`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
|
|
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(
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
296
|
-
|
|
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(
|
|
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]")}
|
|
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 {
|
|
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
|
|
30
|
+
return home;
|
|
28
31
|
if (value.startsWith("~/"))
|
|
29
|
-
return join(
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|