hostwares-cli 2.4.1 → 2.4.2
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 +3990 -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/dist/commands/chat.js
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import { runCliTurn } from "../agent/run.js";
|
|
2
|
-
import { handleSlash, SLASH_NAMES } from "./slash.js";
|
|
3
|
-
import { execShell, formatResult } from "@hostwares/agent-client";
|
|
4
|
-
import { loadSession, newSession, saveSession, appendTurn } from "../session/store.js";
|
|
5
|
-
import { createReplInterface, closeRepl } from "../ui/input.js";
|
|
6
|
-
import { c, glyph } from "../ui/theme.js";
|
|
7
|
-
import * as ui from "../ui/render.js";
|
|
8
|
-
export async function startChat(opts = {}) {
|
|
9
|
-
const cwd = process.cwd();
|
|
10
|
-
const session = loadSession(cwd) ?? newSession(cwd);
|
|
11
|
-
// A session is resumed only when asked for. Silently continuing whatever this
|
|
12
|
-
// directory last discussed makes a new question inherit stale context; the
|
|
13
|
-
// banner says what is available and /resume picks it up.
|
|
14
|
-
if (!opts.resume)
|
|
15
|
-
session.conversationId = null;
|
|
16
|
-
if (opts.oneShot) {
|
|
17
|
-
const code = await sendOne(opts.oneShot, session);
|
|
18
|
-
return code;
|
|
19
|
-
}
|
|
20
|
-
if (opts.resume && session.conversationId) {
|
|
21
|
-
ui.note(`Continuing where you left off (${session.turns.length} turns). /new to start over.`);
|
|
22
|
-
}
|
|
23
|
-
else if (session.turns.length) {
|
|
24
|
-
ui.note(`${session.turns.length} earlier turns in this folder — /resume to continue one.`);
|
|
25
|
-
}
|
|
26
|
-
return repl(session);
|
|
27
|
-
}
|
|
28
|
-
async function sendOne(message, session) {
|
|
29
|
-
const controller = new AbortController();
|
|
30
|
-
const onSigint = () => controller.abort();
|
|
31
|
-
process.on("SIGINT", onSigint);
|
|
32
|
-
try {
|
|
33
|
-
const result = await runCliTurn({ message, session, signal: controller.signal });
|
|
34
|
-
persist(session, message, result.conversationId, result.creditsSpent);
|
|
35
|
-
return result.aborted ? 130 : 0;
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
process.off("SIGINT", onSigint);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
async function repl(session) {
|
|
42
|
-
const prompt = `${c.green("you")} ${c.dim(glyph.arrow)} `;
|
|
43
|
-
const rl = createReplInterface(prompt);
|
|
44
|
-
let leaving = false;
|
|
45
|
-
let busy = null;
|
|
46
|
-
let sigintArmed = false;
|
|
47
|
-
const onSigint = () => {
|
|
48
|
-
if (busy) {
|
|
49
|
-
// Mid-reply: stop the work, keep the session. The abort propagates to
|
|
50
|
-
// fetch and to any permission prompt waiting on stdin.
|
|
51
|
-
busy.abort();
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
if (sigintArmed) {
|
|
55
|
-
leaving = true;
|
|
56
|
-
rl.close();
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
sigintArmed = true;
|
|
60
|
-
ui.line(c.dim(" (Ctrl-C again to leave, or type /exit)"));
|
|
61
|
-
rl.prompt();
|
|
62
|
-
};
|
|
63
|
-
// rl's own SIGINT event, not process's: readline captures the signal while
|
|
64
|
-
// terminal mode is on, so a process-level handler never fires here.
|
|
65
|
-
rl.on("SIGINT", onSigint);
|
|
66
|
-
rl.prompt();
|
|
67
|
-
for await (const raw of rl) {
|
|
68
|
-
const input = raw.trim();
|
|
69
|
-
sigintArmed = false;
|
|
70
|
-
if (!input) {
|
|
71
|
-
rl.prompt();
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
// Inline shell: the user typed it themselves, so it needs no permission
|
|
75
|
-
// prompt and never reaches the model.
|
|
76
|
-
if (input.startsWith("!")) {
|
|
77
|
-
const command = input.slice(1).trim();
|
|
78
|
-
if (command) {
|
|
79
|
-
ui.breakLine();
|
|
80
|
-
ui.line(formatResult(await execShell(command, { timeoutMs: 120_000 })));
|
|
81
|
-
}
|
|
82
|
-
rl.prompt();
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
if (input.startsWith("/")) {
|
|
86
|
-
const controller = new AbortController();
|
|
87
|
-
const result = await handleSlash(input, {
|
|
88
|
-
session,
|
|
89
|
-
quit: () => { leaving = true; rl.close(); },
|
|
90
|
-
signal: controller.signal,
|
|
91
|
-
});
|
|
92
|
-
if (result === "unknown") {
|
|
93
|
-
ui.line(c.dim(` Unknown command. Try ${SLASH_NAMES.slice(0, 4).join(", ")} or /help`));
|
|
94
|
-
}
|
|
95
|
-
if (leaving)
|
|
96
|
-
break;
|
|
97
|
-
rl.prompt();
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
busy = new AbortController();
|
|
101
|
-
try {
|
|
102
|
-
ui.breakLine();
|
|
103
|
-
const result = await runCliTurn({ message: input, session, signal: busy.signal });
|
|
104
|
-
persist(session, input, result.conversationId, result.creditsSpent);
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
busy = null;
|
|
108
|
-
}
|
|
109
|
-
if (leaving)
|
|
110
|
-
break;
|
|
111
|
-
rl.prompt();
|
|
112
|
-
}
|
|
113
|
-
closeRepl();
|
|
114
|
-
saveSession(session);
|
|
115
|
-
if (session.turns.length) {
|
|
116
|
-
ui.line();
|
|
117
|
-
ui.line(c.dim(` ${session.turns.filter(t => t.role === "user").length} messages · ${session.creditsSpent.toFixed(2)} credits this session`));
|
|
118
|
-
}
|
|
119
|
-
return 0;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Record the turn locally.
|
|
123
|
-
*
|
|
124
|
-
* Only the user's side is stored: the assistant's reply already lives in the
|
|
125
|
-
* server transcript, and duplicating it here would double the disk footprint of
|
|
126
|
-
* every session for no benefit. What this file is really for is remembering
|
|
127
|
-
* WHICH conversation this directory belongs to.
|
|
128
|
-
*/
|
|
129
|
-
function persist(session, message, conversationId, credits) {
|
|
130
|
-
if (conversationId)
|
|
131
|
-
session.conversationId = conversationId;
|
|
132
|
-
appendTurn(session, "user", message);
|
|
133
|
-
session.creditsSpent += credits;
|
|
134
|
-
saveSession(session);
|
|
135
|
-
}
|
package/dist/commands/slash.js
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import { listConversations } from "@hostwares/agent-client";
|
|
2
|
-
import { generateSteering } from "@hostwares/agent-client";
|
|
3
|
-
import { rewind, listCheckpoints } from "@hostwares/agent-client";
|
|
4
|
-
import { reportError } from "../errors.js";
|
|
5
|
-
import { setSessionTrust, isProjectTrusted, setProjectTrust, } from "../agent/permissions.js";
|
|
6
|
-
import { LOCAL_TOOL_NAMES } from "@hostwares/agent-client";
|
|
7
|
-
import { getConfig, saveConfig, clearConfig } from "../config.js";
|
|
8
|
-
import { newSession, saveSession, listSessions } from "../session/store.js";
|
|
9
|
-
import { c, glyph } from "../ui/theme.js";
|
|
10
|
-
import * as ui from "../ui/render.js";
|
|
11
|
-
const COMMANDS = [
|
|
12
|
-
{ name: "/help", help: "Show this list" },
|
|
13
|
-
{ name: "/new", help: "Start a fresh conversation" },
|
|
14
|
-
{ name: "/resume", args: "[n]", help: "List recent conversations, or resume the nth" },
|
|
15
|
-
{ name: "/chat", args: "resume", help: "Same as /resume" },
|
|
16
|
-
{ name: "/sessions", help: "Show sessions saved on this machine" },
|
|
17
|
-
{ name: "/context", help: "Context usage for this conversation" },
|
|
18
|
-
{ name: "/tools", help: "Tools that run on this machine" },
|
|
19
|
-
{ name: "/steering", help: "Create .hostwares/steering/*.md so HW knows this project" },
|
|
20
|
-
{ name: "/rewind", args: "[n]", help: "Undo the last change (or the nth checkpoint). Lists them with no arg." },
|
|
21
|
-
{ name: "/trust", help: "Stop asking permission this session" },
|
|
22
|
-
{ name: "/untrust", help: "Ask permission again" },
|
|
23
|
-
{ name: "/model", args: "[name]", help: "Show or set the model" },
|
|
24
|
-
{ name: "/credits", help: "Credit balance" },
|
|
25
|
-
{ name: "/login", help: "Sign in again" },
|
|
26
|
-
{ name: "/logout", help: "Sign out on this machine" },
|
|
27
|
-
{ name: "/exit", help: "Leave" },
|
|
28
|
-
];
|
|
29
|
-
export async function handleSlash(input, ctx) {
|
|
30
|
-
const [cmd, ...rest] = input.trim().split(/\s+/);
|
|
31
|
-
const arg = rest.join(" ");
|
|
32
|
-
switch (cmd) {
|
|
33
|
-
case "/help":
|
|
34
|
-
showHelp();
|
|
35
|
-
return "handled";
|
|
36
|
-
case "/exit":
|
|
37
|
-
case "/quit":
|
|
38
|
-
ctx.quit();
|
|
39
|
-
return "handled";
|
|
40
|
-
case "/new": {
|
|
41
|
-
const fresh = newSession(ctx.session.cwd);
|
|
42
|
-
ctx.session.conversationId = null;
|
|
43
|
-
ctx.session.turns = [];
|
|
44
|
-
ctx.session.createdAt = fresh.createdAt;
|
|
45
|
-
saveSession(ctx.session);
|
|
46
|
-
ui.line(c.dim(" Started a new conversation."));
|
|
47
|
-
return "handled";
|
|
48
|
-
}
|
|
49
|
-
case "/resume":
|
|
50
|
-
case "/chat": // `/chat resume` and `/chat` both land here
|
|
51
|
-
await resume(arg.replace(/^resume\s*/, ""), ctx);
|
|
52
|
-
return "handled";
|
|
53
|
-
case "/sessions":
|
|
54
|
-
showSessions();
|
|
55
|
-
return "handled";
|
|
56
|
-
case "/context":
|
|
57
|
-
showContext(ctx.session);
|
|
58
|
-
return "handled";
|
|
59
|
-
case "/tools":
|
|
60
|
-
showTools();
|
|
61
|
-
return "handled";
|
|
62
|
-
case "/steering":
|
|
63
|
-
scaffoldSteering(ctx.session.cwd);
|
|
64
|
-
return "handled";
|
|
65
|
-
case "/rewind":
|
|
66
|
-
rewindCheckpoint(ctx.session.cwd, arg);
|
|
67
|
-
return "handled";
|
|
68
|
-
case "/trust":
|
|
69
|
-
setSessionTrust(true);
|
|
70
|
-
ui.line(c.dim(" Won't ask again this session. Deletes, kills, pushes and SSH still ask."));
|
|
71
|
-
return "handled";
|
|
72
|
-
case "/untrust":
|
|
73
|
-
setSessionTrust(false);
|
|
74
|
-
if (isProjectTrusted()) {
|
|
75
|
-
setProjectTrust(false);
|
|
76
|
-
ui.line(c.dim(` Will ask again, and removed the saved trust for ${process.cwd()}.`));
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
ui.line(c.dim(" Will ask again."));
|
|
80
|
-
}
|
|
81
|
-
return "handled";
|
|
82
|
-
case "/model":
|
|
83
|
-
setModel(arg);
|
|
84
|
-
return "handled";
|
|
85
|
-
case "/credits":
|
|
86
|
-
await showCredits(ctx.signal);
|
|
87
|
-
return "handled";
|
|
88
|
-
case "/login":
|
|
89
|
-
// Imported lazily: pulling the auth module in at REPL start would run its
|
|
90
|
-
// browser-detection side effects for a command most sessions never use.
|
|
91
|
-
await (await import("../auth/device.js")).login({ signal: ctx.signal });
|
|
92
|
-
return "handled";
|
|
93
|
-
case "/logout":
|
|
94
|
-
clearConfig();
|
|
95
|
-
ui.line(c.dim(" Signed out. Run `hw login` to sign back in."));
|
|
96
|
-
ctx.quit();
|
|
97
|
-
return "handled";
|
|
98
|
-
default:
|
|
99
|
-
return "unknown";
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
function showHelp() {
|
|
103
|
-
ui.line();
|
|
104
|
-
for (const { name, args, help } of COMMANDS) {
|
|
105
|
-
const label = args ? `${name} ${c.dim(args)}` : name;
|
|
106
|
-
ui.line(` ${c.green(label.padEnd(hasArgsPad(name, args)))} ${c.dim(help)}`);
|
|
107
|
-
}
|
|
108
|
-
ui.line();
|
|
109
|
-
ui.line(c.dim(` ${glyph.bullet} Start a line with ! to run a shell command directly`));
|
|
110
|
-
ui.line(c.dim(` ${glyph.bullet} Ctrl-C stops the current reply, twice to leave`));
|
|
111
|
-
ui.line();
|
|
112
|
-
}
|
|
113
|
-
/** Pads the visible name, ignoring the colour codes in the dimmed args. */
|
|
114
|
-
function hasArgsPad(name, args) {
|
|
115
|
-
return 22 + (args ? `${args}`.length + 9 : 0);
|
|
116
|
-
}
|
|
117
|
-
async function resume(arg, ctx) {
|
|
118
|
-
let rows;
|
|
119
|
-
try {
|
|
120
|
-
// This endpoint only became reachable from the CLI once it accepted Bearer
|
|
121
|
-
// auth - it was cookie-only, so `hw` could never list past conversations
|
|
122
|
-
// even though every one of them was stored server-side.
|
|
123
|
-
rows = await listConversations({ apiKey: getConfig().apiKey, baseUrl: getConfig().baseUrl }, { limit: 15, signal: ctx.signal });
|
|
124
|
-
}
|
|
125
|
-
catch (e) {
|
|
126
|
-
reportError(e);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (!rows.length) {
|
|
130
|
-
ui.line(c.dim(" No previous conversations."));
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
const pick = Number(arg);
|
|
134
|
-
if (Number.isInteger(pick) && pick >= 1 && pick <= rows.length) {
|
|
135
|
-
const chosen = rows[pick - 1];
|
|
136
|
-
ctx.session.conversationId = chosen.id;
|
|
137
|
-
ctx.session.turns = [];
|
|
138
|
-
saveSession(ctx.session);
|
|
139
|
-
ui.line(c.dim(` Resumed "${chosen.title}" (${chosen.messageCount} messages).`));
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
ui.line();
|
|
143
|
-
rows.forEach((r, i) => {
|
|
144
|
-
const when = relativeTime(r.updatedAt);
|
|
145
|
-
ui.line(` ${c.bold(String(i + 1).padStart(2))} ${r.title.slice(0, 52).padEnd(52)} ${c.dim(`${r.messageCount} msg · ${when}`)}`);
|
|
146
|
-
});
|
|
147
|
-
ui.line();
|
|
148
|
-
ui.line(c.dim(" /resume <number> to continue one"));
|
|
149
|
-
ui.line();
|
|
150
|
-
}
|
|
151
|
-
function showSessions() {
|
|
152
|
-
const sessions = listSessions();
|
|
153
|
-
if (!sessions.length) {
|
|
154
|
-
ui.line(c.dim(" No saved sessions."));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
ui.line();
|
|
158
|
-
for (const s of sessions.slice(0, 20)) {
|
|
159
|
-
const here = s.cwd === process.cwd() ? c.green(` ${glyph.tick} here`) : "";
|
|
160
|
-
ui.line(` ${s.cwd}${here}`);
|
|
161
|
-
ui.line(c.dim(` ${s.turnCount} turns · ${s.creditsSpent.toFixed(2)} credits · ${relativeTime(s.updatedAt)}`));
|
|
162
|
-
}
|
|
163
|
-
ui.line();
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Context usage.
|
|
167
|
-
*
|
|
168
|
-
* The server owns the real transcript, so anything meaningful here is whatever
|
|
169
|
-
* the last reply reported. Saying so is the point: the old `/context` invented
|
|
170
|
-
* a number from the CLI's local array (characters ÷ 4 ÷ 180000), which excluded
|
|
171
|
-
* the system prompt and tool schemas entirely and therefore always read 0%.
|
|
172
|
-
*/
|
|
173
|
-
function showContext(session) {
|
|
174
|
-
ui.line();
|
|
175
|
-
ui.line(` Conversation: ${session.conversationId ? c.dim(session.conversationId) : c.dim("not started yet")}`);
|
|
176
|
-
ui.line(` Turns here: ${session.turns.length}`);
|
|
177
|
-
ui.line(` Project: ${c.dim(session.cwd)}`);
|
|
178
|
-
ui.line();
|
|
179
|
-
ui.line(c.dim(" Context size is reported by the server after each reply — see the ctx"));
|
|
180
|
-
ui.line(c.dim(" figure in the status line. When a session grows past the window the"));
|
|
181
|
-
ui.line(c.dim(" earlier part is summarised automatically and work continues — you do"));
|
|
182
|
-
ui.line(c.dim(" not need to start over."));
|
|
183
|
-
ui.line();
|
|
184
|
-
}
|
|
185
|
-
function showTools() {
|
|
186
|
-
ui.line();
|
|
187
|
-
ui.line(` ${c.bold(`${LOCAL_TOOL_NAMES.length} tools run on this machine:`)}`);
|
|
188
|
-
ui.line();
|
|
189
|
-
// Four columns, alphabetical, so the count is verifiable at a glance rather
|
|
190
|
-
// than asserted in prose that then drifts.
|
|
191
|
-
const sorted = [...LOCAL_TOOL_NAMES].sort();
|
|
192
|
-
const rows = Math.ceil(sorted.length / 4);
|
|
193
|
-
for (let r = 0; r < rows; r++) {
|
|
194
|
-
const cells = [0, 1, 2, 3]
|
|
195
|
-
.map(col => sorted[col * rows + r])
|
|
196
|
-
.filter(Boolean)
|
|
197
|
-
.map(n => c.dim(n.padEnd(20)));
|
|
198
|
-
ui.line(` ${cells.join("")}`);
|
|
199
|
-
}
|
|
200
|
-
ui.line();
|
|
201
|
-
ui.line(c.dim(" Hostwares account tools (sites, databases, DNS, billing) run on the server."));
|
|
202
|
-
ui.line();
|
|
203
|
-
}
|
|
204
|
-
function scaffoldSteering(cwd) {
|
|
205
|
-
ui.line();
|
|
206
|
-
try {
|
|
207
|
-
const { created, skipped } = generateSteering(cwd);
|
|
208
|
-
if (created.length) {
|
|
209
|
-
ui.line(` ${c.bold("Created steering files")} ${c.dim("in .hostwares/steering/")}`);
|
|
210
|
-
for (const f of created)
|
|
211
|
-
ui.line(` ${c.green(glyph.tick)} ${f}`);
|
|
212
|
-
ui.line();
|
|
213
|
-
ui.line(c.dim(" Edit them to describe your project — they load into every session so HW"));
|
|
214
|
-
ui.line(c.dim(" stops re-deriving your conventions."));
|
|
215
|
-
}
|
|
216
|
-
if (skipped.length) {
|
|
217
|
-
ui.line(c.dim(` Left alone (already exist): ${skipped.join(", ")}`));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch (e) {
|
|
221
|
-
ui.line(` ${c.red(glyph.warn)} Could not create steering files: ${e.message}`);
|
|
222
|
-
}
|
|
223
|
-
ui.line();
|
|
224
|
-
}
|
|
225
|
-
function rewindCheckpoint(cwd, arg) {
|
|
226
|
-
ui.line();
|
|
227
|
-
const checkpoints = listCheckpoints(cwd);
|
|
228
|
-
if (!checkpoints.length) {
|
|
229
|
-
ui.line(c.dim(" Nothing to rewind — no changes have been checkpointed in this project yet."));
|
|
230
|
-
ui.line();
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
// No arg: list the checkpoints so the user can pick one.
|
|
234
|
-
if (!arg.trim()) {
|
|
235
|
-
ui.line(` ${c.bold("Checkpoints")} ${c.dim("(newest first — /rewind undoes #1, /rewind N undoes the Nth)")}`);
|
|
236
|
-
ui.line();
|
|
237
|
-
checkpoints.slice(0, 10).forEach((cp, i) => {
|
|
238
|
-
const when = new Date(cp.createdAt).toLocaleString();
|
|
239
|
-
const note = cp.note ? ` — ${cp.note.slice(0, 50)}` : "";
|
|
240
|
-
ui.line(` ${c.bold(String(i + 1))}. ${c.dim(`${cp.fileCount} file${cp.fileCount === 1 ? "" : "s"} · ${when}`)}${c.dim(note)}`);
|
|
241
|
-
});
|
|
242
|
-
ui.line();
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
// Arg: rewind the Nth checkpoint (1-based), or the latest if not a number.
|
|
246
|
-
const n = parseInt(arg.trim(), 10);
|
|
247
|
-
const label = Number.isFinite(n) && n >= 1 && checkpoints[n - 1] ? checkpoints[n - 1].label : undefined;
|
|
248
|
-
const result = rewind(cwd, label);
|
|
249
|
-
if (result.ok) {
|
|
250
|
-
ui.line(` ${c.green(glyph.tick)} ${result.message}`);
|
|
251
|
-
for (const f of result.restored)
|
|
252
|
-
ui.line(c.dim(` restored ${f}`));
|
|
253
|
-
for (const f of result.deleted)
|
|
254
|
-
ui.line(c.dim(` removed ${f}`));
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
ui.line(` ${c.red(glyph.warn)} ${result.message}`);
|
|
258
|
-
}
|
|
259
|
-
ui.line();
|
|
260
|
-
}
|
|
261
|
-
function setModel(arg) {
|
|
262
|
-
const ALLOWED = ["auto", "claude-sonnet-5", "claude-opus-5"];
|
|
263
|
-
if (!arg) {
|
|
264
|
-
ui.line(` Model: ${c.bold(getConfig().model ?? "auto")}`);
|
|
265
|
-
ui.line(c.dim(` Options: ${ALLOWED.join(", ")}`));
|
|
266
|
-
ui.line(c.dim(" auto picks per task — cheaper for lookups, stronger for debugging."));
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
if (!ALLOWED.includes(arg)) {
|
|
270
|
-
ui.line(c.red(` Unknown model "${arg}".`));
|
|
271
|
-
ui.line(c.dim(` Options: ${ALLOWED.join(", ")}`));
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
saveConfig({ model: arg });
|
|
275
|
-
ui.line(c.dim(` Model set to ${arg}.`));
|
|
276
|
-
if (arg === "claude-opus-5")
|
|
277
|
-
ui.line(c.dim(" Opus needs a Business plan; other plans fall back to Sonnet."));
|
|
278
|
-
}
|
|
279
|
-
async function showCredits(signal) {
|
|
280
|
-
try {
|
|
281
|
-
const cfg = getConfig();
|
|
282
|
-
const r = await fetch(`${cfg.baseUrl}/api/credits`, {
|
|
283
|
-
headers: { Authorization: `Bearer ${cfg.apiKey}` }, signal,
|
|
284
|
-
});
|
|
285
|
-
const res = r.ok ? await r.json() : null;
|
|
286
|
-
const balance = res?.total ?? res?.balance;
|
|
287
|
-
if (typeof balance === "number")
|
|
288
|
-
ui.line(` Credits: ${c.bold(balance.toFixed(2))}`);
|
|
289
|
-
else
|
|
290
|
-
ui.line(c.dim(" Could not read your balance."));
|
|
291
|
-
}
|
|
292
|
-
catch (e) {
|
|
293
|
-
reportError(e);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
function relativeTime(iso) {
|
|
297
|
-
const diff = Date.now() - Date.parse(iso);
|
|
298
|
-
if (!Number.isFinite(diff))
|
|
299
|
-
return "unknown";
|
|
300
|
-
const mins = Math.floor(diff / 60_000);
|
|
301
|
-
if (mins < 1)
|
|
302
|
-
return "just now";
|
|
303
|
-
if (mins < 60)
|
|
304
|
-
return `${mins}m ago`;
|
|
305
|
-
const hours = Math.floor(mins / 60);
|
|
306
|
-
if (hours < 24)
|
|
307
|
-
return `${hours}h ago`;
|
|
308
|
-
return `${Math.floor(hours / 24)}d ago`;
|
|
309
|
-
}
|
|
310
|
-
export const SLASH_NAMES = COMMANDS.map(c => c.name);
|
package/dist/config.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { homedir } from "os";
|
|
2
|
-
import { join, dirname } from "path";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, unlinkSync, } from "fs";
|
|
4
|
-
/**
|
|
5
|
-
* On-disk configuration: the API key and which host to talk to.
|
|
6
|
-
*
|
|
7
|
-
* Two things this file is strict about, both of which the previous version got
|
|
8
|
-
* wrong:
|
|
9
|
-
*
|
|
10
|
-
* 1. **Permissions.** The config holds a live API key with full account access.
|
|
11
|
-
* It was previously written at the process umask default (0644) - world
|
|
12
|
-
* readable on a shared machine. It is now created 0700/0600 and re-chmod'd
|
|
13
|
-
* on every write, because a file created before this change keeps its old
|
|
14
|
-
* mode forever otherwise.
|
|
15
|
-
*
|
|
16
|
-
* 2. **Atomicity.** A crash or a full disk mid-write used to leave a truncated
|
|
17
|
-
* config, which reads back as invalid JSON and logs the user out. Writes go
|
|
18
|
-
* to a temp file in the same directory and are renamed into place.
|
|
19
|
-
*/
|
|
20
|
-
export const HW_DIR = join(homedir(), ".hostwares");
|
|
21
|
-
const CONFIG_FILE = join(HW_DIR, "config.json");
|
|
22
|
-
export const DEFAULT_BASE_URL = "https://hostwares.com";
|
|
23
|
-
const EMPTY = { apiKey: "", baseUrl: DEFAULT_BASE_URL };
|
|
24
|
-
let _cache = null;
|
|
25
|
-
/** Read config from disk. Cached per-process; call `reloadConfig()` after a login. */
|
|
26
|
-
export function getConfig() {
|
|
27
|
-
if (_cache)
|
|
28
|
-
return _cache;
|
|
29
|
-
_cache = readConfigFromDisk();
|
|
30
|
-
return _cache;
|
|
31
|
-
}
|
|
32
|
-
export function reloadConfig() {
|
|
33
|
-
_cache = readConfigFromDisk();
|
|
34
|
-
return _cache;
|
|
35
|
-
}
|
|
36
|
-
function readConfigFromDisk() {
|
|
37
|
-
if (!existsSync(CONFIG_FILE))
|
|
38
|
-
return { ...EMPTY };
|
|
39
|
-
try {
|
|
40
|
-
const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
41
|
-
return {
|
|
42
|
-
// A truncated or hand-edited config must degrade to "logged out", never
|
|
43
|
-
// to a crash on startup.
|
|
44
|
-
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : "",
|
|
45
|
-
baseUrl: typeof parsed.baseUrl === "string" && parsed.baseUrl ? parsed.baseUrl : DEFAULT_BASE_URL,
|
|
46
|
-
trustedProjects: Array.isArray(parsed.trustedProjects)
|
|
47
|
-
? parsed.trustedProjects.filter((p) => typeof p === "string")
|
|
48
|
-
: [],
|
|
49
|
-
model: typeof parsed.model === "string" ? parsed.model : undefined,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return { ...EMPTY };
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
export function saveConfig(patch) {
|
|
57
|
-
const next = { ...getConfig(), ...patch };
|
|
58
|
-
writeJsonSecure(CONFIG_FILE, next);
|
|
59
|
-
_cache = next;
|
|
60
|
-
return next;
|
|
61
|
-
}
|
|
62
|
-
export function clearConfig() {
|
|
63
|
-
try {
|
|
64
|
-
unlinkSync(CONFIG_FILE);
|
|
65
|
-
}
|
|
66
|
-
catch { /* already gone */ }
|
|
67
|
-
_cache = { ...EMPTY };
|
|
68
|
-
}
|
|
69
|
-
export function isAuthenticated() {
|
|
70
|
-
return Boolean(getConfig().apiKey);
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Write JSON atomically with owner-only permissions.
|
|
74
|
-
*
|
|
75
|
-
* Exported because the session store needs the identical guarantees - session
|
|
76
|
-
* files carry conversation text, and a half-written one loses a session.
|
|
77
|
-
*/
|
|
78
|
-
export function writeJsonSecure(file, value) {
|
|
79
|
-
const dir = dirname(file);
|
|
80
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
81
|
-
// mkdir's mode is masked by umask and is a no-op if the directory already
|
|
82
|
-
// exists, so set it explicitly.
|
|
83
|
-
try {
|
|
84
|
-
chmodSync(dir, 0o700);
|
|
85
|
-
}
|
|
86
|
-
catch { /* not fatal */ }
|
|
87
|
-
// Same directory as the target: rename is only atomic within a filesystem.
|
|
88
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
89
|
-
writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
90
|
-
try {
|
|
91
|
-
chmodSync(tmp, 0o600);
|
|
92
|
-
}
|
|
93
|
-
catch { /* not fatal */ }
|
|
94
|
-
renameSync(tmp, file);
|
|
95
|
-
}
|
package/dist/errors.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { ApiError } from "@hostwares/agent-client";
|
|
2
|
-
import * as ui from "./ui/render.js";
|
|
3
|
-
/**
|
|
4
|
-
* Turn an error into one actionable line.
|
|
5
|
-
*
|
|
6
|
-
* Every branch says what the user can DO. "Request failed" plus a stack trace
|
|
7
|
-
* is what the old client printed, and it left people with no next step.
|
|
8
|
-
*/
|
|
9
|
-
export function reportError(e) {
|
|
10
|
-
ui.breakLine();
|
|
11
|
-
if (e instanceof ApiError) {
|
|
12
|
-
if (e.isOutOfCredits) {
|
|
13
|
-
ui.error("You're out of credits.");
|
|
14
|
-
ui.note("Ask me to buy credits — billing and support messages are free.");
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
if (e.status === 429) {
|
|
18
|
-
ui.error("Too many requests in a row.");
|
|
19
|
-
ui.note("Wait a few seconds and try again.");
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
if (e.code === "ai_terms_required") {
|
|
23
|
-
ui.error("You need to accept the AI agent terms before using this.");
|
|
24
|
-
ui.note(String(e.body.termsUrl ?? "https://hostwares.com/terms#ai-agent"));
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
if (e.isAuthFailure) {
|
|
28
|
-
ui.error("Not signed in. Run `hw login`.");
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
ui.error(e.message);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
ui.error(e instanceof Error ? e.message : String(e));
|
|
35
|
-
}
|