hostwares-cli 2.4.1 → 2.4.3
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/dist/index.js +4000 -175
- package/package.json +4 -5
- package/dist/agent/permissions.js +0 -230
- package/dist/agent/run.js +0 -185
- package/dist/auth/device.js +0 -131
- package/dist/commands/chat.js +0 -135
- package/dist/commands/slash.js +0 -310
- package/dist/config.js +0 -95
- package/dist/errors.js +0 -35
- package/dist/session/store.js +0 -144
- package/dist/ui/input.js +0 -242
- package/dist/ui/render.js +0 -355
- package/dist/ui/theme.js +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hostwares-cli",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.3",
|
|
4
4
|
"description": "Hostwares CLI - AI DevOps in your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "
|
|
19
|
+
"build": "node scripts/build.mjs",
|
|
20
20
|
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
21
21
|
"test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\"",
|
|
22
22
|
"start": "node dist/index.js"
|
|
@@ -41,10 +41,9 @@
|
|
|
41
41
|
"url": "https://github.com/Hostwares/hostwares-cli/issues"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
+
"@hostwares/agent-client": "github:Hostwares/hostwares-agent-client#v1",
|
|
44
45
|
"@types/node": "^20.0.0",
|
|
46
|
+
"esbuild": "^0.28.2",
|
|
45
47
|
"typescript": "^5.5.0"
|
|
46
|
-
},
|
|
47
|
-
"dependencies": {
|
|
48
|
-
"@hostwares/agent-client": "github:Hostwares/hostwares-agent-client#v1"
|
|
49
48
|
}
|
|
50
49
|
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { getConfig, saveConfig } from "../config.js";
|
|
2
|
-
import { readKey, pickScope } from "../ui/input.js";
|
|
3
|
-
import { c, glyph } from "../ui/theme.js";
|
|
4
|
-
import { breakLine, humanTool } from "../ui/render.js";
|
|
5
|
-
import { decidePermission, loadPermissionRules } from "@hostwares/agent-client";
|
|
6
|
-
/**
|
|
7
|
-
* Whether a local tool may run, and how the user says so.
|
|
8
|
-
*
|
|
9
|
-
* Trust has two scopes, and the distinction matters:
|
|
10
|
-
*
|
|
11
|
-
* session - in memory, gone when the process exits. What `t` grants.
|
|
12
|
-
* project - persisted per absolute project path in the config file. What
|
|
13
|
-
* `a` grants, and what `/trust` toggles.
|
|
14
|
-
*
|
|
15
|
-
* The old version had only the first, and `/trust` / `/untrust` did not
|
|
16
|
-
* actually touch it - they printed "✓ All actions trusted this session" and
|
|
17
|
-
* returned, while `trustMode` stayed false and `resetTrust` was exported twice
|
|
18
|
-
* and called nowhere. A user who ran `/trust` was told they had trusted the
|
|
19
|
-
* session and was then prompted for every single tool.
|
|
20
|
-
*/
|
|
21
|
-
/** Read-only tools. Prompting for these is noise that trains users to hit `t`. */
|
|
22
|
-
const READ_ONLY = new Set([
|
|
23
|
-
"read_file", "list_directory", "get_system_info",
|
|
24
|
-
"git_status", "git_log", "git_diff", "check_github_auth",
|
|
25
|
-
"docker_ps", "list_processes", "check_port",
|
|
26
|
-
"get_process_output", "open_browser", "wait_for_url",
|
|
27
|
-
"web_search", "web_fetch",
|
|
28
|
-
]);
|
|
29
|
-
/**
|
|
30
|
-
* Tools that always prompt, even under trust.
|
|
31
|
-
*
|
|
32
|
-
* `search_files` and `curl_request` are NOT auto-allowed despite being
|
|
33
|
-
* read-only in intent: the first walks arbitrary paths and the second is an
|
|
34
|
-
* outbound request to a model-chosen URL, which is how local data leaves the
|
|
35
|
-
* machine. Neither is dangerous enough to always prompt, so they sit in the
|
|
36
|
-
* normal prompting tier rather than here.
|
|
37
|
-
*
|
|
38
|
-
* These four are irreversible or reach outside this machine, so a blanket
|
|
39
|
-
* "trust everything" must not cover them silently.
|
|
40
|
-
*/
|
|
41
|
-
const ALWAYS_ASK = new Set(["delete_path", "kill_process", "ssh_run", "git_push"]);
|
|
42
|
-
let sessionTrust = false;
|
|
43
|
-
let sessionRules = [];
|
|
44
|
-
export function isSessionTrusted() { return sessionTrust; }
|
|
45
|
-
export function setSessionTrust(on) { sessionTrust = on; }
|
|
46
|
-
export function getSessionRules() { return sessionRules; }
|
|
47
|
-
export function addSessionRule(rule) { sessionRules.push(rule); }
|
|
48
|
-
// Glob permission rules from ~/.hostwares/permissions.json and the project's
|
|
49
|
-
// .hostwares/permissions.json. Loaded once and reused; a user who edits them
|
|
50
|
-
// mid-session can restart. Lets `allow: npm *` and `deny: sudo *` be honoured
|
|
51
|
-
// without a prompt, and enforces the non-negotiable hard denies (rm -rf /, ...).
|
|
52
|
-
let cachedRules = null;
|
|
53
|
-
function permissionRules() {
|
|
54
|
-
if (cachedRules === null) {
|
|
55
|
-
try {
|
|
56
|
-
cachedRules = loadPermissionRules(process.cwd());
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
cachedRules = [];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return cachedRules;
|
|
63
|
-
}
|
|
64
|
-
export function reloadPermissionRules() { cachedRules = null; }
|
|
65
|
-
export function isProjectTrusted(cwd = process.cwd()) {
|
|
66
|
-
return (getConfig().trustedProjects ?? []).includes(cwd);
|
|
67
|
-
}
|
|
68
|
-
export function setProjectTrust(on, cwd = process.cwd()) {
|
|
69
|
-
const current = new Set(getConfig().trustedProjects ?? []);
|
|
70
|
-
if (on)
|
|
71
|
-
current.add(cwd);
|
|
72
|
-
else
|
|
73
|
-
current.delete(cwd);
|
|
74
|
-
saveConfig({ trustedProjects: [...current] });
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Ask whether `tool` may run.
|
|
78
|
-
*
|
|
79
|
-
* Returns "abort" for Ctrl-C / Esc so the caller can stop the whole turn rather
|
|
80
|
-
* than treating it as a denial of this one tool and carrying on - the old code
|
|
81
|
-
* mapped every non-`y`/`t` key, Ctrl-C included, to a silent "no" and continued
|
|
82
|
-
* to the next tool.
|
|
83
|
-
*/
|
|
84
|
-
export async function askPermission(tool) {
|
|
85
|
-
// Session-scoped rules (from Kiro-style `t` → scope picker) are checked first
|
|
86
|
-
if (sessionRules.length) {
|
|
87
|
-
const v = decidePermission(tool.name, tool.input, sessionRules, "none", tool.destructive);
|
|
88
|
-
if (v === "allow")
|
|
89
|
-
return "allow";
|
|
90
|
-
if (v === "deny")
|
|
91
|
-
return "deny";
|
|
92
|
-
}
|
|
93
|
-
const trust = sessionTrust ? "session" : isProjectTrusted() ? "project" : "none";
|
|
94
|
-
// Glob rules and hard denies decide first, when they have an opinion.
|
|
95
|
-
const verdict = decidePermission(tool.name, tool.input, permissionRules(), trust, tool.destructive);
|
|
96
|
-
if (verdict === "deny") {
|
|
97
|
-
// A denied command (hard safety rule, or a user's own deny rule) is refused
|
|
98
|
-
// outright, not offered as a prompt - there is no safe "yes".
|
|
99
|
-
process.stdout.write(`\n${c.red(glyph.warn)} ${c.red("Refused:")} ${describe(tool)} ${c.dim("(blocked by a permission rule)")}\n`);
|
|
100
|
-
return "deny";
|
|
101
|
-
}
|
|
102
|
-
if (verdict === "allow")
|
|
103
|
-
return "allow";
|
|
104
|
-
// verdict === "ask" falls through to the interactive prompt below.
|
|
105
|
-
if (READ_ONLY.has(tool.name))
|
|
106
|
-
return "allow";
|
|
107
|
-
const alwaysAsk = ALWAYS_ASK.has(tool.name) || tool.destructive;
|
|
108
|
-
if (!alwaysAsk && (sessionTrust || isProjectTrusted()))
|
|
109
|
-
return "allow";
|
|
110
|
-
breakLine();
|
|
111
|
-
process.stdout.write(`\n${c.yellow(glyph.caret)} ${describe(tool)}\n`);
|
|
112
|
-
const consequence = consequenceOf(tool);
|
|
113
|
-
if (consequence)
|
|
114
|
-
process.stdout.write(` ${c.red(glyph.warn)} ${c.dim(consequence)}\n`);
|
|
115
|
-
// `t` and `a` are withheld for always-ask tools: offering "trust everything"
|
|
116
|
-
// on the prompt for `rm -rf` invites exactly the reflex that makes the prompt
|
|
117
|
-
// worthless.
|
|
118
|
-
const options = alwaysAsk
|
|
119
|
-
? `${c.bold("y")} yes ${c.bold("n")} no ${c.dim("esc cancel")}`
|
|
120
|
-
: `${c.bold("y")} yes ${c.bold("n")} no ${c.bold("t")} trust this session ${c.bold("a")} always in this project ${c.dim("esc cancel")}`;
|
|
121
|
-
process.stdout.write(` ${c.dim(options)}: `);
|
|
122
|
-
const key = await readKey(alwaysAsk ? ["y", "n"] : ["y", "n", "t", "a"]);
|
|
123
|
-
switch (key) {
|
|
124
|
-
case "y": return "allow";
|
|
125
|
-
case "t": {
|
|
126
|
-
// Kiro-style scoped trust: Specific paths / Directory / Entire Tool
|
|
127
|
-
const toolPath = String(tool.input.path ?? tool.input.localPath ?? "");
|
|
128
|
-
const rel = toolPath ? shortRel(toolPath) : "";
|
|
129
|
-
const dir = rel ? rel.split("/").slice(0, -1).join("/") || "." : "";
|
|
130
|
-
const scopeOpts = [
|
|
131
|
-
`Specific paths → ${rel || tool.name} ${c.dim("(this file only)")}`,
|
|
132
|
-
`Complete directory → ${dir || "."} ${c.dim("(all files here)")}`,
|
|
133
|
-
`Entire Tool → * ${c.dim(`(always allow '${tool.name}')`)}`,
|
|
134
|
-
];
|
|
135
|
-
// Strip dim codes for pickScope display — we render with codes ourselves
|
|
136
|
-
const displayOpts = [
|
|
137
|
-
`Specific paths → ${rel || tool.name}`,
|
|
138
|
-
`Complete directory → ${dir || "."}`,
|
|
139
|
-
`Entire Tool → *`,
|
|
140
|
-
];
|
|
141
|
-
const choice = await pickScope(displayOpts);
|
|
142
|
-
if (choice === -1)
|
|
143
|
-
return "abort";
|
|
144
|
-
if (choice === 0) {
|
|
145
|
-
const match = rel || "*";
|
|
146
|
-
addSessionRule({ tool: tool.name, match, effect: "allow" });
|
|
147
|
-
process.stdout.write(` ${c.dim(`Trusted '${tool.name}' for ${match} this session.`)}\n`);
|
|
148
|
-
}
|
|
149
|
-
else if (choice === 1) {
|
|
150
|
-
const match = dir ? `${dir}/*` : "*";
|
|
151
|
-
addSessionRule({ tool: tool.name, match, effect: "allow" });
|
|
152
|
-
process.stdout.write(` ${c.dim(`Trusted '${tool.name}' for ${match} this session.`)}\n`);
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
// Entire Tool → blanket session trust for this tool only (narrower than old global)
|
|
156
|
-
addSessionRule({ tool: tool.name, match: "*", effect: "allow" });
|
|
157
|
-
// Also keep old global for file tools that have no path? For now tool-scoped is enough.
|
|
158
|
-
process.stdout.write(` ${c.dim(`Trusted '${tool.name}' (*) this session.`)}` + "\n");
|
|
159
|
-
}
|
|
160
|
-
return "allow";
|
|
161
|
-
}
|
|
162
|
-
case "a":
|
|
163
|
-
setProjectTrust(true);
|
|
164
|
-
process.stdout.write(` ${c.dim(`Always allowed in ${process.cwd()}.`)}\n`);
|
|
165
|
-
return "allow";
|
|
166
|
-
case "ctrl-c":
|
|
167
|
-
case "escape": return "abort";
|
|
168
|
-
// Includes "" from a non-TTY: with no interactive user there is nobody to
|
|
169
|
-
// grant permission, and defaulting to allow would let a piped script run
|
|
170
|
-
// arbitrary commands unattended.
|
|
171
|
-
default: return "deny";
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
function shortRel(absPath) {
|
|
175
|
-
const cwd = process.cwd();
|
|
176
|
-
if (absPath.startsWith(cwd + "/"))
|
|
177
|
-
return absPath.slice(cwd.length + 1);
|
|
178
|
-
const home = process.env.HOME;
|
|
179
|
-
if (home && absPath.startsWith(home + "/"))
|
|
180
|
-
return "~/" + absPath.slice(home.length + 1);
|
|
181
|
-
return absPath;
|
|
182
|
-
}
|
|
183
|
-
/** One line saying exactly what will happen, in the user's terms. */
|
|
184
|
-
function describe(tool) {
|
|
185
|
-
const i = tool.input;
|
|
186
|
-
const s = (k) => (i[k] === undefined ? "" : String(i[k]));
|
|
187
|
-
const t = (v, n = 70) => (v.length > n ? `${v.slice(0, n - 1)}…` : v);
|
|
188
|
-
switch (tool.name) {
|
|
189
|
-
case "run_command": return `Run ${c.bold(t(s("command"), 90))}`;
|
|
190
|
-
case "start_process": return `Start in background ${c.bold(t(s("command"), 80))}${s("name") ? c.dim(` (as ${s("name")})`) : ""}`;
|
|
191
|
-
case "stop_process": return `Stop background process ${c.bold(s("name"))}`;
|
|
192
|
-
case "write_file": return `Write ${c.bold(s("path"))} ${c.dim(`(${String(i.content ?? "").length} chars)`)}`;
|
|
193
|
-
case "append_file": return `Append to ${c.bold(s("path"))}`;
|
|
194
|
-
case "str_replace_file": {
|
|
195
|
-
const old = String(i.oldStr ?? "").split("\n")[0]?.slice(0, 60) ?? "";
|
|
196
|
-
return `Edit ${c.bold(s("path"))}${old ? c.dim(` — replace "${old}…"`) : ""}`;
|
|
197
|
-
}
|
|
198
|
-
case "delete_path": return `Delete ${c.bold(s("path"))}`;
|
|
199
|
-
case "search_files": return `Search for ${c.bold(t(s("pattern")))}`;
|
|
200
|
-
case "install_package": return `Install package ${c.bold(s("name"))}`;
|
|
201
|
-
case "git_add": return `Stage ${c.bold(s("files"))}`;
|
|
202
|
-
case "git_commit": return `Commit: ${c.bold(t(s("message")))}`;
|
|
203
|
-
case "git_push": return `Push to ${c.bold(s("remote") || "origin")}${s("branch") ? `/${s("branch")}` : ""}`;
|
|
204
|
-
case "git_pull": return `Pull from ${c.bold(s("remote") || "origin")}`;
|
|
205
|
-
case "git_clone": return `Clone ${c.bold(s("url"))}`;
|
|
206
|
-
case "git_branch": return `Branch: ${c.bold(s("action"))}${s("name") ? ` ${s("name")}` : ""}`;
|
|
207
|
-
case "ssh_run": return `Run on ${c.bold(`${s("user")}@${s("host")}`)}: ${c.bold(t(s("command")))}`;
|
|
208
|
-
case "ssh_upload": return `Upload ${c.bold(s("localPath"))} to ${c.bold(`${s("user")}@${s("host")}:${s("remotePath")}`)}`;
|
|
209
|
-
case "ssh_download": return `Download ${c.bold(`${s("user")}@${s("host")}:${s("remotePath")}`)}`;
|
|
210
|
-
case "kill_process": return `Kill process ${c.bold(s("target"))}`;
|
|
211
|
-
case "docker_exec": return `Run in container ${c.bold(s("container"))}: ${c.bold(t(s("command")))}`;
|
|
212
|
-
case "docker_logs": return `Read logs from ${c.bold(s("container"))}`;
|
|
213
|
-
case "curl_request": return `${c.bold(s("method") || "GET")} ${c.bold(t(s("url")))}`;
|
|
214
|
-
default: return humanTool(tool.name);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
/** Stated plainly, and only where there is a real consequence to state. */
|
|
218
|
-
function consequenceOf(tool) {
|
|
219
|
-
const i = tool.input;
|
|
220
|
-
switch (tool.name) {
|
|
221
|
-
case "delete_path": return "Permanently removes this path and everything under it. There is no recycle bin.";
|
|
222
|
-
case "git_push": return "Publishes commits to the remote, where others may already have pulled from it.";
|
|
223
|
-
case "git_branch": return i.action === "delete" ? "Deletes the branch. Unmerged commits on it become unreachable." : null;
|
|
224
|
-
case "ssh_run": return "Runs on a remote server, outside this machine.";
|
|
225
|
-
case "kill_process": return "Terminates the process immediately; unsaved work in it is lost.";
|
|
226
|
-
case "write_file": return "Overwrites the file if it already exists.";
|
|
227
|
-
case "docker_exec": return "Runs inside a container, which may hold live data.";
|
|
228
|
-
default: return null;
|
|
229
|
-
}
|
|
230
|
-
}
|
package/dist/agent/run.js
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { runTurn } from "@hostwares/agent-client";
|
|
2
|
-
import { getConfig } from "../config.js";
|
|
3
|
-
import { reauthenticate } from "../auth/device.js";
|
|
4
|
-
import { askPermission, isSessionTrusted, isProjectTrusted } from "./permissions.js";
|
|
5
|
-
import { confirm } from "../ui/input.js";
|
|
6
|
-
import { c } from "../ui/theme.js";
|
|
7
|
-
import * as ui from "../ui/render.js";
|
|
8
|
-
export async function runCliTurn(opts) {
|
|
9
|
-
const config = getConfig();
|
|
10
|
-
const spinner = new ui.Spinner();
|
|
11
|
-
// Tool-call ids whose command was already shown in an approval prompt, so
|
|
12
|
-
// tool:start does not render the same command a second time.
|
|
13
|
-
const promptedIds = new Set();
|
|
14
|
-
/**
|
|
15
|
-
* Live output, bounded.
|
|
16
|
-
*
|
|
17
|
-
* A running command has to look like it is working - that is the whole point
|
|
18
|
-
* of streaming - but the terminal must never become the file. Asking for a
|
|
19
|
-
* todo app used to print the entire 300-line HTML document, because the tool
|
|
20
|
-
* result WAS the file and nothing decided what to show instead.
|
|
21
|
-
*
|
|
22
|
-
* So: the first lines stream as they arrive, then output is counted rather
|
|
23
|
-
* than printed, and the tail is reported as a number. The model still
|
|
24
|
-
* receives the full result; only the screen is bounded.
|
|
25
|
-
*/
|
|
26
|
-
const MAX_LIVE_LINES = 12;
|
|
27
|
-
const live = new Map();
|
|
28
|
-
const track = (id) => {
|
|
29
|
-
let s = live.get(id);
|
|
30
|
-
if (!s) {
|
|
31
|
-
s = { shown: 0, total: 0, partial: "" };
|
|
32
|
-
live.set(id, s);
|
|
33
|
-
}
|
|
34
|
-
return s;
|
|
35
|
-
};
|
|
36
|
-
const feed = (id, chunk) => {
|
|
37
|
-
const st = track(id);
|
|
38
|
-
st.partial += chunk;
|
|
39
|
-
const lines = st.partial.split("\n");
|
|
40
|
-
st.partial = lines.pop() ?? "";
|
|
41
|
-
for (const line of lines) {
|
|
42
|
-
st.total++;
|
|
43
|
-
if (st.shown < MAX_LIVE_LINES) {
|
|
44
|
-
ui.toolOutputLine(line);
|
|
45
|
-
st.shown++;
|
|
46
|
-
}
|
|
47
|
-
else if (st.shown === MAX_LIVE_LINES) {
|
|
48
|
-
ui.toolOutputLine(ui.dim("… still running, output hidden"));
|
|
49
|
-
st.shown++;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
const finish = (id) => {
|
|
54
|
-
const st = live.get(id);
|
|
55
|
-
if (!st)
|
|
56
|
-
return { hidden: 0 };
|
|
57
|
-
if (st.partial.trim()) {
|
|
58
|
-
st.total++;
|
|
59
|
-
if (st.shown < MAX_LIVE_LINES) {
|
|
60
|
-
ui.toolOutputLine(st.partial);
|
|
61
|
-
st.shown++;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
live.delete(id);
|
|
65
|
-
return { hidden: Math.max(0, st.total - Math.min(st.shown, MAX_LIVE_LINES)) };
|
|
66
|
-
};
|
|
67
|
-
const host = {
|
|
68
|
-
cwd: process.cwd(),
|
|
69
|
-
askPermission: (call) => {
|
|
70
|
-
// Record that this tool showed its command in the approval prompt, so the
|
|
71
|
-
// subsequent tool:start does not print the same command a second time.
|
|
72
|
-
promptedIds.add(call.id);
|
|
73
|
-
return askPermission(call);
|
|
74
|
-
},
|
|
75
|
-
confirmAction: async (action) => {
|
|
76
|
-
ui.breakLine();
|
|
77
|
-
const risk = action.risk === "critical" ? c.red(action.risk)
|
|
78
|
-
: action.risk === "high" ? c.yellow(action.risk)
|
|
79
|
-
: c.dim(action.risk);
|
|
80
|
-
ui.line(`${c.yellow("▸")} ${action.description} ${c.dim(`(${risk})`)}`);
|
|
81
|
-
// Default no: a queued action changes live infrastructure, and Enter
|
|
82
|
-
// should never be the thing that deploys something.
|
|
83
|
-
return confirm(" Run this?", false);
|
|
84
|
-
},
|
|
85
|
-
trustLevel: () => (isSessionTrusted() || isProjectTrusted()) ? "session" : "none",
|
|
86
|
-
onActivity: (e) => {
|
|
87
|
-
switch (e.type) {
|
|
88
|
-
case "thinking":
|
|
89
|
-
spinner.start("Working");
|
|
90
|
-
break;
|
|
91
|
-
case "text":
|
|
92
|
-
spinner.stop();
|
|
93
|
-
ui.write(e.chunk);
|
|
94
|
-
break;
|
|
95
|
-
case "tool:start":
|
|
96
|
-
spinner.stop();
|
|
97
|
-
// If the user was just asked to approve this exact tool, its command
|
|
98
|
-
// was already shown in the prompt — don't print the header again.
|
|
99
|
-
if (promptedIds.has(e.id)) {
|
|
100
|
-
promptedIds.delete(e.id);
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
ui.toolRunning(e.tool, e.target);
|
|
104
|
-
}
|
|
105
|
-
break;
|
|
106
|
-
case "tool:output":
|
|
107
|
-
spinner.stop();
|
|
108
|
-
feed(e.id, e.chunk);
|
|
109
|
-
break;
|
|
110
|
-
case "tool:end": {
|
|
111
|
-
// A loop-guard short-circuit is not a tool failure — it's HW
|
|
112
|
-
// catching itself re-reading. Show it as a note, not a red ✗.
|
|
113
|
-
if (e.summary && /loop guard/i.test(e.summary)) {
|
|
114
|
-
spinner.stop();
|
|
115
|
-
ui.note("Skipped a repeated read (already have it) — moving on.");
|
|
116
|
-
break;
|
|
117
|
-
}
|
|
118
|
-
const { hidden } = finish(e.id);
|
|
119
|
-
if (hidden > 0)
|
|
120
|
-
ui.toolOutputLine(ui.dim(`… +${hidden} more line${hidden === 1 ? "" : "s"}`));
|
|
121
|
-
// One completion line, with the file verb + size folded in — no
|
|
122
|
-
// separate chip printing after "Completed" and reading backwards.
|
|
123
|
-
ui.toolDone(e.tool, e.ok ? "success" : "failed", e.durationMs, e.effect, e.bytes);
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
case "tool:denied":
|
|
127
|
-
ui.toolSkipped(e.tool);
|
|
128
|
-
break;
|
|
129
|
-
case "action:done":
|
|
130
|
-
if (e.ok)
|
|
131
|
-
ui.success(e.detail?.split("\n")[0] ?? "Done");
|
|
132
|
-
else
|
|
133
|
-
ui.note(e.detail ?? "Cancelled");
|
|
134
|
-
break;
|
|
135
|
-
case "turn:end":
|
|
136
|
-
spinner.stop();
|
|
137
|
-
ui.breakLine();
|
|
138
|
-
ui.statusLine({
|
|
139
|
-
balance: e.usage.balance,
|
|
140
|
-
turnCredits: e.usage.credits,
|
|
141
|
-
elapsedMs: e.usage.elapsedMs,
|
|
142
|
-
context: e.usage,
|
|
143
|
-
model: e.usage.model,
|
|
144
|
-
});
|
|
145
|
-
break;
|
|
146
|
-
case "reauth":
|
|
147
|
-
spinner.stop();
|
|
148
|
-
break;
|
|
149
|
-
case "error":
|
|
150
|
-
spinner.stop();
|
|
151
|
-
ui.error(e.message);
|
|
152
|
-
break;
|
|
153
|
-
// tool:pending is not drawn: askPermission is about to print a prompt
|
|
154
|
-
// for this exact tool, and a row plus a prompt for one action reads as
|
|
155
|
-
// two things happening.
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
};
|
|
159
|
-
try {
|
|
160
|
-
const result = await runTurn({
|
|
161
|
-
credentials: { apiKey: config.apiKey, baseUrl: config.baseUrl, userAgent: `hostwares-cli/${VERSION}` },
|
|
162
|
-
message: opts.message,
|
|
163
|
-
conversationId: opts.session.conversationId,
|
|
164
|
-
model: config.model,
|
|
165
|
-
signal: opts.signal,
|
|
166
|
-
host,
|
|
167
|
-
onAuthFailure: () => reauthenticate(opts.signal),
|
|
168
|
-
});
|
|
169
|
-
if (result.aborted) {
|
|
170
|
-
ui.breakLine();
|
|
171
|
-
ui.note("Stopped.");
|
|
172
|
-
}
|
|
173
|
-
return {
|
|
174
|
-
conversationId: result.conversationId,
|
|
175
|
-
creditsSpent: result.creditsSpent,
|
|
176
|
-
aborted: result.aborted,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
finally {
|
|
180
|
-
spinner.stop();
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
/** Set once at startup from package.json, for the User-Agent header. */
|
|
184
|
-
let VERSION = "0.0.0";
|
|
185
|
-
export function setVersion(v) { VERSION = v; }
|
package/dist/auth/device.js
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { exec } from "@hostwares/agent-client";
|
|
2
|
-
import { saveConfig, reloadConfig, getConfig, DEFAULT_BASE_URL } from "../config.js";
|
|
3
|
-
import { c, glyph } from "../ui/theme.js";
|
|
4
|
-
import { line, note, success, error } from "../ui/render.js";
|
|
5
|
-
import { AbortedError } from "@hostwares/agent-client";
|
|
6
|
-
/**
|
|
7
|
-
* Run the full device flow and persist the key.
|
|
8
|
-
*
|
|
9
|
-
* Returns true on success. Never throws for an ordinary failure (expired code,
|
|
10
|
-
* user gave up) - the caller decides what a failed login means in context.
|
|
11
|
-
*/
|
|
12
|
-
export async function login(opts = {}) {
|
|
13
|
-
// baseUrl comes from config so a self-hosted or staging install keeps
|
|
14
|
-
// working. The old code hardcoded https://hostwares.com inside the login
|
|
15
|
-
// action, which silently overwrote any custom baseUrl on every login.
|
|
16
|
-
const base = getConfig().baseUrl || DEFAULT_BASE_URL;
|
|
17
|
-
let start;
|
|
18
|
-
try {
|
|
19
|
-
const res = await fetch(`${base}/api/auth/device`, { method: "POST", signal: opts.signal });
|
|
20
|
-
if (!res.ok) {
|
|
21
|
-
error(`Could not start login (HTTP ${res.status}). Try again in a moment.`);
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
start = await res.json();
|
|
25
|
-
}
|
|
26
|
-
catch (e) {
|
|
27
|
-
if (opts.signal?.aborted)
|
|
28
|
-
throw new AbortedError();
|
|
29
|
-
error("Could not reach Hostwares to start login. Check your connection.");
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
if (!start.device_code || !start.user_code || !start.verification_url) {
|
|
33
|
-
error("Login response from the server was incomplete. Please report this.");
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
if (!opts.quiet)
|
|
37
|
-
line();
|
|
38
|
-
line(` Open this URL to authorise:`);
|
|
39
|
-
line(` ${c.cyan(start.verification_url)}`);
|
|
40
|
-
line();
|
|
41
|
-
line(` Confirmation code: ${c.bold(start.user_code)}`);
|
|
42
|
-
line();
|
|
43
|
-
if (!opts.noBrowser)
|
|
44
|
-
openBrowser(start.verification_url);
|
|
45
|
-
// The server's own interval and expiry are honoured. The old client polled
|
|
46
|
-
// every 3s for a fixed 120 iterations regardless of what the server said, so
|
|
47
|
-
// it kept polling for six minutes against a code that expires in ten - and
|
|
48
|
-
// would have hammered a server asking it to slow down.
|
|
49
|
-
const intervalMs = Math.max(1000, (start.interval ?? 3) * 1000);
|
|
50
|
-
const deadline = Date.now() + (start.expires_in ?? 600) * 1000;
|
|
51
|
-
note(`Waiting for authorisation… ${c.dim("(Ctrl-C to cancel)")}`);
|
|
52
|
-
while (Date.now() < deadline) {
|
|
53
|
-
if (opts.signal?.aborted)
|
|
54
|
-
throw new AbortedError();
|
|
55
|
-
await sleep(intervalMs, opts.signal);
|
|
56
|
-
let poll;
|
|
57
|
-
try {
|
|
58
|
-
poll = await fetch(`${base}/api/auth/device-poll?code=${encodeURIComponent(start.device_code)}`, { signal: opts.signal });
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
// A transient network blip must not abandon a login the user is halfway
|
|
62
|
-
// through in their browser. Keep polling until the code actually expires.
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
// 429 slow_down: back off rather than treating it as a failure.
|
|
66
|
-
if (poll.status === 429) {
|
|
67
|
-
await sleep(intervalMs, opts.signal);
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
// The body is JSON, but a proxy can return HTML on an error status. The old
|
|
71
|
-
// client called poll.json() unguarded, so that threw an unhandled rejection
|
|
72
|
-
// and killed the CLI mid-login.
|
|
73
|
-
let data;
|
|
74
|
-
try {
|
|
75
|
-
data = await poll.json();
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (data.status === "approved" && data.token) {
|
|
81
|
-
saveConfig({ apiKey: data.token, baseUrl: base });
|
|
82
|
-
reloadConfig();
|
|
83
|
-
if (!opts.quiet)
|
|
84
|
-
success("Signed in.");
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
if (data.error === "expired" || poll.status === 410) {
|
|
88
|
-
error("That code expired. Run `hw login` again.");
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
if (poll.status === 404) {
|
|
92
|
-
error("That login request is no longer valid. Run `hw login` again.");
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
// status "pending" - keep waiting.
|
|
96
|
-
}
|
|
97
|
-
error("Login timed out. Run `hw login` to try again.");
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Re-authenticate mid-session, then let the caller replay.
|
|
102
|
-
*
|
|
103
|
-
* Deliberately terse: this interrupts something the user was already doing, so
|
|
104
|
-
* it explains itself in one line and gets out of the way.
|
|
105
|
-
*/
|
|
106
|
-
export async function reauthenticate(signal) {
|
|
107
|
-
line();
|
|
108
|
-
note(`${glyph.warn} Your sign-in expired. Reconnecting…`);
|
|
109
|
-
const ok = await login({ quiet: true, signal });
|
|
110
|
-
if (ok)
|
|
111
|
-
note(`${glyph.tick} Reconnected — carrying on.`);
|
|
112
|
-
return ok;
|
|
113
|
-
}
|
|
114
|
-
function openBrowser(url) {
|
|
115
|
-
// Best-effort: a headless box or a locked-down desktop has no browser to
|
|
116
|
-
// open, and the URL is already printed above, so failure is not worth
|
|
117
|
-
// reporting. Note the URL is an argv entry, never part of a shell string.
|
|
118
|
-
const [bin, args] = process.platform === "darwin" ? ["open", [url]]
|
|
119
|
-
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
120
|
-
: ["xdg-open", [url]];
|
|
121
|
-
try {
|
|
122
|
-
exec(bin, args, { timeoutMs: 5000 });
|
|
123
|
-
}
|
|
124
|
-
catch { /* ignore */ }
|
|
125
|
-
}
|
|
126
|
-
function sleep(ms, signal) {
|
|
127
|
-
return new Promise((resolve, reject) => {
|
|
128
|
-
const t = setTimeout(resolve, ms);
|
|
129
|
-
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new AbortedError()); }, { once: true });
|
|
130
|
-
});
|
|
131
|
-
}
|