howtofixcli 0.7.24 → 0.7.26

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.
Files changed (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4931 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-56SEAGAQ.js +0 -1
  132. package/dist/chunk-5AWTL34S.js +0 -1
  133. package/dist/chunk-75P2SCOT.js +0 -1
  134. package/dist/chunk-7CHDNTH6.js +0 -1
  135. package/dist/chunk-7PHRNXLN.js +0 -34
  136. package/dist/chunk-AGGIYXN7.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-C5E3SFOQ.js +0 -2
  139. package/dist/chunk-CMTKCUL5.js +0 -1
  140. package/dist/chunk-DAPXGSGD.js +0 -12
  141. package/dist/chunk-DOD7MUGX.js +0 -1
  142. package/dist/chunk-FPXVDP32.js +0 -8
  143. package/dist/chunk-GXB7RZHH.js +0 -1
  144. package/dist/chunk-I7KVLBMF.js +0 -2
  145. package/dist/chunk-ILDDDLRC.js +0 -1
  146. package/dist/chunk-JFYVRWJI.js +0 -2
  147. package/dist/chunk-LAHVHRCS.js +0 -2
  148. package/dist/chunk-LSJC73DG.js +0 -1
  149. package/dist/chunk-MNDKS6UH.js +0 -6
  150. package/dist/chunk-N345KWWK.js +0 -1
  151. package/dist/chunk-N3J2J7EQ.js +0 -5
  152. package/dist/chunk-ONZLNFQ2.js +0 -3
  153. package/dist/chunk-ORJ2QZBG.js +0 -3
  154. package/dist/chunk-PCALBZOO.js +0 -2
  155. package/dist/chunk-QPHSHL7H.js +0 -2
  156. package/dist/chunk-TKROS3BE.js +0 -5
  157. package/dist/chunk-TSEKPGK4.js +0 -1
  158. package/dist/chunk-UP7ENLEL.js +0 -117
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-XW6LMQGX.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
@@ -0,0 +1,292 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { extname, join, relative, resolve, sep } from "node:path";
3
+ import { Type } from "@netrodev/hxf-ai";
4
+ import { defineTool } from "@netrodev/hxf-coding-agent";
5
+ /**
6
+ * A ranked outline of the repository, built without a parser dependency.
7
+ *
8
+ * The model cannot read a large tree, and listing every file is worse than
9
+ * useless: it spends the budget evenly on files that matter and files that do
10
+ * not. This walks the tree, pulls the declarations out of each file, follows the
11
+ * relative imports between them, and ranks by how much of the code depends on a
12
+ * file — the same idea as a tree-sitter repo map, done with regexes so it works
13
+ * on any machine without native builds.
14
+ *
15
+ * The ranking is the point. A file that ten others import is more likely to be
16
+ * the one worth reading than a file nobody mentions, and a symbol outline says
17
+ * what is in it before a single line is opened.
18
+ */
19
+ const SKIP_DIRS = new Set([
20
+ "node_modules",
21
+ ".git",
22
+ "dist",
23
+ "build",
24
+ "out",
25
+ "coverage",
26
+ "vendor",
27
+ "target",
28
+ "__pycache__",
29
+ ".venv",
30
+ "venv",
31
+ ".next",
32
+ ".turbo",
33
+ ".cache",
34
+ ]);
35
+ const LANGUAGES = {
36
+ ".ts": {
37
+ kind: "ts",
38
+ patterns: [
39
+ /^\s*export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
40
+ /^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/gm,
41
+ /^\s*export\s+(?:interface|type)\s+([A-Za-z_$][\w$]*)/gm,
42
+ /^\s*export\s+const\s+([A-Za-z_$][\w$]*)/gm,
43
+ /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
44
+ /^\s*(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/gm,
45
+ ],
46
+ },
47
+ ".tsx": { kind: "tsx", patterns: [] },
48
+ ".js": { kind: "js", patterns: [] },
49
+ ".mjs": { kind: "js", patterns: [] },
50
+ ".go": {
51
+ kind: "go",
52
+ patterns: [
53
+ /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm,
54
+ /^type\s+([A-Za-z_]\w*)\s+(?:struct|interface)/gm,
55
+ ],
56
+ },
57
+ ".py": {
58
+ kind: "py",
59
+ patterns: [/^(?:async\s+)?def\s+([A-Za-z_]\w*)/gm, /^class\s+([A-Za-z_]\w*)/gm],
60
+ },
61
+ ".rs": {
62
+ kind: "rs",
63
+ patterns: [
64
+ /^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)/gm,
65
+ /^\s*(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_]\w*)/gm,
66
+ ],
67
+ },
68
+ };
69
+ // TSX/JS share the TypeScript patterns; declared separately so the table reads
70
+ // as "which extensions are walked", not "which extensions have a parser".
71
+ LANGUAGES[".tsx"].patterns = LANGUAGES[".ts"].patterns;
72
+ LANGUAGES[".js"].patterns = LANGUAGES[".ts"].patterns;
73
+ LANGUAGES[".mjs"].patterns = LANGUAGES[".ts"].patterns;
74
+ const MAX_FILE_BYTES = 512 * 1024;
75
+ const MAX_DEPTH = 12;
76
+ /** Declarations in one file, deduplicated and in source order. */
77
+ export function extractSymbols(text, ext) {
78
+ const language = LANGUAGES[ext];
79
+ if (!language)
80
+ return [];
81
+ const seen = new Set();
82
+ const out = [];
83
+ for (const pattern of language.patterns) {
84
+ pattern.lastIndex = 0;
85
+ for (const match of text.matchAll(pattern)) {
86
+ const name = match[1];
87
+ if (name && !seen.has(name)) {
88
+ seen.add(name);
89
+ out.push(name);
90
+ }
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+ /** Relative import specifiers, as written in the file. */
96
+ export function extractImports(text, ext) {
97
+ const found = new Set();
98
+ const add = (spec) => {
99
+ if (spec && (spec.startsWith("./") || spec.startsWith("../")))
100
+ found.add(spec);
101
+ };
102
+ if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".mjs") {
103
+ for (const match of text.matchAll(/(?:from|require\()\s*["']([^"']+)["']/g))
104
+ add(match[1]);
105
+ for (const match of text.matchAll(/^\s*import\s+["']([^"']+)["']/gm))
106
+ add(match[1]);
107
+ }
108
+ else if (ext === ".go") {
109
+ for (const match of text.matchAll(/^\s*(?:[\w.]+\s+)?"([^"]+)"/gm))
110
+ add(match[1]);
111
+ }
112
+ else if (ext === ".py") {
113
+ for (const match of text.matchAll(/^\s*from\s+(\.+[\w.]*)\s+import/gm)) {
114
+ const spec = match[1].replace(/\./g, "/");
115
+ add(spec.startsWith("/") ? `.${spec}` : `./${spec}`);
116
+ }
117
+ }
118
+ else if (ext === ".rs") {
119
+ for (const match of text.matchAll(/^\s*use\s+(?:crate|super|self)::([\w:]+)/gm)) {
120
+ add(`./${match[1].split("::").join("/")}`);
121
+ }
122
+ }
123
+ return [...found];
124
+ }
125
+ /** Resolve an import specifier to a file that is in the walked set. */
126
+ function resolveImport(fromPath, spec, known) {
127
+ const base = fromPath.split("/").slice(0, -1).join("/");
128
+ const target = join(base, spec).split(sep).join("/").replace(/^\.\//, "");
129
+ const candidates = [
130
+ target,
131
+ `${target}.ts`,
132
+ `${target}.tsx`,
133
+ `${target}.js`,
134
+ `${target}.mjs`,
135
+ `${target}.go`,
136
+ `${target}.py`,
137
+ `${target}.rs`,
138
+ `${target}/index.ts`,
139
+ `${target}/index.tsx`,
140
+ `${target}/index.js`,
141
+ `${target}/mod.rs`,
142
+ `${target}.py`,
143
+ ];
144
+ return candidates.find((candidate) => known.has(candidate));
145
+ }
146
+ /** Walk the tree, skipping the directories nobody wants in a map. */
147
+ export function collectFiles(root, subdir = ".") {
148
+ const files = [];
149
+ const walk = (dir, depth) => {
150
+ if (depth > MAX_DEPTH)
151
+ return;
152
+ let entries;
153
+ try {
154
+ entries = readdirSync(dir, { withFileTypes: true });
155
+ }
156
+ catch {
157
+ return;
158
+ }
159
+ for (const entry of entries) {
160
+ const full = join(dir, entry.name);
161
+ if (entry.isDirectory()) {
162
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
163
+ continue;
164
+ walk(full, depth + 1);
165
+ continue;
166
+ }
167
+ const ext = extname(entry.name);
168
+ if (!LANGUAGES[ext])
169
+ continue;
170
+ try {
171
+ if (statSync(full).size > MAX_FILE_BYTES)
172
+ continue;
173
+ const text = readFileSync(full, "utf8");
174
+ const rel = relative(root, full).split(sep).join("/");
175
+ files.push({ path: rel, symbols: extractSymbols(text, ext), imports: extractImports(text, ext) });
176
+ }
177
+ catch {
178
+ // An unreadable file is not a reason to fail the map.
179
+ }
180
+ }
181
+ };
182
+ walk(resolve(root, subdir), 0);
183
+ return files;
184
+ }
185
+ /**
186
+ * Rank files by how much of the tree depends on them.
187
+ *
188
+ * PageRank over the import graph, with a small personalisation toward files
189
+ * that mention the query. Twenty iterations is far past convergence for a
190
+ * repository of this size and keeps the call cheap.
191
+ */
192
+ export function rankFiles(files, query) {
193
+ if (files.length === 0)
194
+ return [];
195
+ const known = new Set(files.map((file) => file.path));
196
+ const index = new Map(files.map((file, i) => [file.path, i]));
197
+ const out = files.map(() => []);
198
+ const incoming = files.map(() => []);
199
+ files.forEach((file, i) => {
200
+ for (const spec of file.imports) {
201
+ const target = resolveImport(file.path, spec, known);
202
+ if (!target || target === file.path)
203
+ continue;
204
+ const j = index.get(target);
205
+ if (j === undefined)
206
+ continue;
207
+ out[i].push(j);
208
+ incoming[j].push(i);
209
+ }
210
+ });
211
+ const n = files.length;
212
+ const needle = query?.trim().toLowerCase();
213
+ const personal = files.map((file) => {
214
+ if (!needle)
215
+ return 1 / n;
216
+ const haystack = `${file.path} ${file.symbols.join(" ")}`.toLowerCase();
217
+ return haystack.includes(needle) ? 4 / n : 1 / n;
218
+ });
219
+ const personalTotal = personal.reduce((sum, value) => sum + value, 0);
220
+ let rank = files.map((_, i) => personal[i] / personalTotal);
221
+ for (let iteration = 0; iteration < 20; iteration++) {
222
+ const next = files.map((_, i) => 0.15 * (personal[i] / personalTotal));
223
+ for (let i = 0; i < n; i++) {
224
+ if (out[i].length === 0) {
225
+ // A file that imports nothing spreads its weight over the whole tree,
226
+ // which is what keeps a leaf-only repository from collapsing to zero.
227
+ for (let j = 0; j < n; j++)
228
+ next[j] += (0.85 * rank[i]) / n;
229
+ continue;
230
+ }
231
+ for (const j of out[i])
232
+ next[j] += (0.85 * rank[i]) / out[i].length;
233
+ }
234
+ rank = next;
235
+ }
236
+ return files
237
+ .map((file, i) => ({
238
+ file,
239
+ score: rank[i],
240
+ // A query is a statement about what the caller is looking for, so a file
241
+ // that names it is worth more than any amount of graph weight: the ranking
242
+ // still orders the matches among themselves.
243
+ match: needle ? `${file.path} ${file.symbols.join(" ")}`.toLowerCase().includes(needle) : false,
244
+ }))
245
+ .sort((a, b) => Number(b.match) - Number(a.match) || b.score - a.score || a.file.path.localeCompare(b.file.path))
246
+ .map((entry) => entry.file);
247
+ }
248
+ /** Render a ranked map inside a character budget (roughly 4 chars per token). */
249
+ export function renderRepoMap(files, options = {}) {
250
+ const budget = Math.max(200, Math.min(8000, options.budget ?? 1200));
251
+ const chars = budget * 4;
252
+ const ranked = rankFiles(files, options.query);
253
+ const lines = [];
254
+ let used = 0;
255
+ for (const file of ranked) {
256
+ const symbols = file.symbols.slice(0, 8).join(", ");
257
+ const line = symbols ? `${file.path}: ${symbols}` : `${file.path}`;
258
+ if (used + line.length + 1 > chars)
259
+ break;
260
+ lines.push(line);
261
+ used += line.length + 1;
262
+ }
263
+ if (lines.length === 0)
264
+ return "(no source files found)";
265
+ const omitted = ranked.length - lines.length;
266
+ if (omitted > 0)
267
+ lines.push(`… ${omitted} more file${omitted === 1 ? "" : "s"} not shown`);
268
+ return lines.join("\n");
269
+ }
270
+ let projectRoot;
271
+ /** The session's project directory, supplied by the caller for the same reason
272
+ * claims take one: a tool cannot know which project the process is in. */
273
+ export function setRepoRoot(next) {
274
+ projectRoot = next;
275
+ }
276
+ export const repoMapTool = defineTool({
277
+ name: "repo_map",
278
+ label: "repo_map",
279
+ description: "A ranked outline of the repository: files by importance with their top declarations. Use it to find where something lives before reading files, instead of listing directories.",
280
+ promptSnippet: "repo_map: ranked file outline of the project",
281
+ parameters: Type.Object({
282
+ path: Type.Optional(Type.String({ description: "Subdirectory to map, relative to the project root" })),
283
+ query: Type.Optional(Type.String({ description: "Keyword to bias the ranking, e.g. 'billing'" })),
284
+ budget: Type.Optional(Type.Number({ description: "Token budget, default 1200" })),
285
+ }),
286
+ async execute(_toolCallId, params) {
287
+ const root = projectRoot?.() ?? process.cwd();
288
+ const files = collectFiles(root, params.path ?? ".");
289
+ const text = renderRepoMap(files, { budget: params.budget, query: params.query });
290
+ return { content: [{ type: "text", text }], details: { files: files.length } };
291
+ },
292
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `/roblox login | logout | status | tunnel` — the CLI's side of the Roblox
3
+ * connection.
4
+ *
5
+ * The bridge (`@netrodev/howtofix-roblox-bridge`) runs beside the agent and owns
6
+ * the Roblox sign-in, the virtual place and the tunnel. This module is a thin
7
+ * client for its loopback REST surface: it finds the bridge through the state file
8
+ * the bridge writes, asks it to do the thing, and reports what happened. Nothing
9
+ * Roblox-specific lives here, so the CLI stays small and the logic stays where the
10
+ * tests are.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { openInBrowser } from "./browser.js";
15
+ /** Where the bridge recorded its port and token, or undefined when none runs. */
16
+ export function readBridgeEndpoint(stateDir) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(join(stateDir, "roblox-bridge.json"), "utf8"));
19
+ if (typeof parsed.port === "number" && typeof parsed.token === "string")
20
+ return { port: parsed.port, token: parsed.token };
21
+ }
22
+ catch {
23
+ /* no bridge has started here */
24
+ }
25
+ return undefined;
26
+ }
27
+ const NO_BRIDGE = "the Roblox bridge is not running. Install RoToolbox with /preset, restart, and it starts with the agent.";
28
+ export class RobloxConnect {
29
+ endpoint;
30
+ fetcher;
31
+ open;
32
+ constructor(endpoint, fetcher = fetch, open = (url) => openInBrowser(url)) {
33
+ this.endpoint = endpoint;
34
+ this.fetcher = fetcher;
35
+ this.open = open;
36
+ }
37
+ base() {
38
+ if (!this.endpoint)
39
+ throw new Error(NO_BRIDGE);
40
+ return `http://127.0.0.1:${this.endpoint.port}`;
41
+ }
42
+ async get(path) {
43
+ const response = await this.fetcher(`${this.base()}${path}`);
44
+ const body = (await response.json().catch(() => ({})));
45
+ if (!response.ok)
46
+ throw new Error(body.error ?? `the bridge answered ${response.status}`);
47
+ return body;
48
+ }
49
+ async status() {
50
+ return this.get("/roblox/status");
51
+ }
52
+ /**
53
+ * Open Roblox's consent page and wait until the bridge reports a sign-in.
54
+ *
55
+ * The URL is always returned too: a machine with no browser (SSH, a container)
56
+ * cannot open it, and the person has to be able to paste it somewhere that can.
57
+ */
58
+ async login(options = {}) {
59
+ const status = await this.status();
60
+ if (!status.oauth.configured) {
61
+ throw new Error("no Roblox OAuth app is configured. Set HOWTOFIX_ROBLOX_CLIENT_ID (or use ROBLOX_API_KEY for an API key).");
62
+ }
63
+ const { url } = await this.get("/oauth/start");
64
+ options.onUrl?.(url);
65
+ const opened = await this.open(url);
66
+ const deadline = Date.now() + (options.timeoutMs ?? 5 * 60_000);
67
+ while (Date.now() < deadline) {
68
+ await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? 1000));
69
+ const now = await this.status();
70
+ if (now.oauth.signedIn) {
71
+ return { ok: true, message: `signed in${now.oauth.userName ? ` as ${now.oauth.userName}` : ""}`, url };
72
+ }
73
+ }
74
+ return {
75
+ ok: false,
76
+ message: opened ? "timed out waiting for the Roblox consent screen" : "could not open a browser; open the URL yourself, then run /roblox status",
77
+ url,
78
+ };
79
+ }
80
+ async logout() {
81
+ if (!this.endpoint)
82
+ throw new Error(NO_BRIDGE);
83
+ await this.get(`/oauth/logout?token=${encodeURIComponent(this.endpoint.token)}`);
84
+ }
85
+ async tunnel(action) {
86
+ if (!this.endpoint)
87
+ throw new Error(NO_BRIDGE);
88
+ const response = await this.fetcher(`${this.base()}/tunnel`, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json", "x-howtofix-token": this.endpoint.token },
91
+ body: JSON.stringify({ action }),
92
+ });
93
+ const body = (await response.json().catch(() => ({})));
94
+ if (!response.ok)
95
+ throw new Error(body.error ?? `the bridge answered ${response.status}`);
96
+ return body;
97
+ }
98
+ }
99
+ /** The sentence(s) `/roblox status` shows. */
100
+ export function describeStatus(status, tunnel) {
101
+ const lines = [];
102
+ if (status.oauth.signedIn) {
103
+ lines.push(`Roblox: signed in${status.oauth.userName ? ` as ${status.oauth.userName}` : ""}`);
104
+ lines.push(status.oauth.universeIds.length > 0
105
+ ? `experiences: ${status.oauth.universeIds.join(", ")}`
106
+ : "experiences: none granted yet (sign in again and tick one)");
107
+ }
108
+ else if (status.openCloud) {
109
+ lines.push("Roblox: using an API key (ROBLOX_API_KEY)");
110
+ }
111
+ else {
112
+ lines.push(`Roblox: not signed in${status.oauth.configured ? " — run /roblox login" : " — set HOWTOFIX_ROBLOX_CLIENT_ID to enable sign-in"}`);
113
+ }
114
+ lines.push(`virtual places: ${status.places.join(", ") || "none"} (answer tools when Studio is closed)`);
115
+ if (tunnel)
116
+ lines.push(`workspace tunnel: ${tunnel.open ? "connected" : tunnel.running ? "connecting" : "off"}`);
117
+ return lines.join("\n");
118
+ }
119
+ /** Parse `/roblox <sub> [args]`; undefined means the legacy RoToolbox status. */
120
+ export function parseRobloxSubcommand(arg) {
121
+ const [first = "", ...rest] = arg.trim().split(/\s+/);
122
+ if (first === "login" || first === "logout" || first === "status" || first === "tunnel")
123
+ return { sub: first, rest: rest.join(" ") };
124
+ return undefined;
125
+ }
package/dist/rpc.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `howtofix --mode rpc`: drive a session from another process, one JSON object
3
+ * per line.
4
+ *
5
+ * The same contract the harness's own RPC mode speaks for the commands that make
6
+ * a session useful from outside — `prompt`, `abort`, `get_state`,
7
+ * `get_last_assistant_text` — with every session event streamed on stdout in the
8
+ * `--output-format stream-json` shape. A host that already parses that stream
9
+ * needs nothing new, and a host that wants a conversation gets one without a
10
+ * terminal.
11
+ *
12
+ * The CLI opens sessions through `createAgentSession`, which the harness's
13
+ * `runRpcMode` does not accept (it wants a full runtime host with session
14
+ * switching and forking). Rather than rebuild the CLI around a different
15
+ * constructor, this drives the session the CLI already has, so every extension it
16
+ * loads — MCP, code mode, the Roblox bridge — is present in RPC mode too.
17
+ *
18
+ * stdout carries only protocol lines. Anything a person should read goes to
19
+ * stderr, because one stray line would corrupt the stream for the host.
20
+ */
21
+ import { createInterface } from "node:readline";
22
+ /** Run until the input closes. Resolves when in-flight work has finished. */
23
+ export function runRpc(options) {
24
+ const { agent } = options;
25
+ const write = (message) => options.output.write(`${JSON.stringify(message)}\n`);
26
+ const respond = (id, command, success, extra = {}) => write({ ...(id !== undefined ? { id } : {}), type: "response", command, success, ...extra });
27
+ const unsubscribe = agent.subscribe((event) => {
28
+ const encoded = options.encodeEvent ? options.encodeEvent(event) : `${JSON.stringify(event)}\n`;
29
+ if (encoded !== undefined)
30
+ options.output.write(encoded);
31
+ });
32
+ let running = null;
33
+ return new Promise((resolve) => {
34
+ const lines = createInterface({ input: options.input });
35
+ lines.on("line", (line) => {
36
+ if (line.trim() === "")
37
+ return;
38
+ let command;
39
+ try {
40
+ command = JSON.parse(line);
41
+ }
42
+ catch {
43
+ respond(undefined, "parse", false, { error: "a command must be one JSON object per line" });
44
+ return;
45
+ }
46
+ const { id, type } = command;
47
+ if (type === "prompt") {
48
+ if (typeof command.message !== "string" || command.message.trim() === "") {
49
+ respond(id, "prompt", false, { error: "prompt needs a message" });
50
+ return;
51
+ }
52
+ if (running) {
53
+ respond(id, "prompt", false, { error: "a prompt is already running; abort it or wait" });
54
+ return;
55
+ }
56
+ // Acknowledged before it runs: the host learns the command was accepted
57
+ // immediately and follows the work through the event stream.
58
+ respond(id, "prompt", true);
59
+ running = agent
60
+ .prompt(command.message)
61
+ .catch((error) => {
62
+ write({ type: "error", message: error instanceof Error ? error.message : String(error) });
63
+ })
64
+ .finally(() => {
65
+ running = null;
66
+ });
67
+ return;
68
+ }
69
+ if (type === "abort") {
70
+ void agent.abort().then(() => respond(id, "abort", true));
71
+ return;
72
+ }
73
+ if (type === "get_state") {
74
+ respond(id, "get_state", true, { data: { running: running !== null, ...(options.state?.() ?? {}) } });
75
+ return;
76
+ }
77
+ if (type === "get_last_assistant_text") {
78
+ respond(id, "get_last_assistant_text", true, { data: { text: agent.getLastAssistantText() ?? null } });
79
+ return;
80
+ }
81
+ respond(id, String(type ?? "unknown"), false, { error: `unknown command ${String(type)}` });
82
+ });
83
+ lines.on("close", () => {
84
+ void (running ?? Promise.resolve()).then(async () => {
85
+ unsubscribe();
86
+ await agent.dispose();
87
+ resolve();
88
+ });
89
+ });
90
+ });
91
+ }