hatcher-connect 0.1.0

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 (3) hide show
  1. package/README.md +52 -0
  2. package/dist/index.js +824 -0
  3. package/package.json +49 -0
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # Hatcher Workspace Connector
2
+
3
+ Standalone connector that gives one Hatcher agent controlled access to one
4
+ dedicated folder on the user's computer. It does not depend on the retired
5
+ Hatcher CLI.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install --global hatcher-connect
11
+ ```
12
+
13
+ This installs the `hatcher-connect` command.
14
+
15
+ ## Connect a folder
16
+
17
+ ```bash
18
+ hatcher-connect login
19
+ hatcher-connect agents
20
+ hatcher-connect connect --agent <agent-id> --folder ./agent-workspace
21
+ ```
22
+
23
+ Use `--read-only` to allow listing, searching, and reading without file
24
+ changes. The connector remains in the foreground; Ctrl+C disconnects the
25
+ folder immediately.
26
+
27
+ `HATCHER_API_KEY` and `HATCHER_API_URL` can replace saved credentials in
28
+ automated environments. The API key is sent in an Authorization header and is
29
+ never placed in the WebSocket URL.
30
+
31
+ ## File safety
32
+
33
+ - Every path is relative to the selected canonical folder.
34
+ - Absolute paths, traversal, root `.hatcher` access, and symbolic links are
35
+ rejected.
36
+ - Files larger than 512 KiB are not read; writes are limited to 45 KiB.
37
+ - Replacing a file requires the SHA-256 revision returned by the last read.
38
+ - Previous contents are stored in `.hatcher/history`.
39
+ - Deletes are moved to `.hatcher/trash` and remain recoverable.
40
+ - The filesystem root and the user's home directory cannot be shared.
41
+
42
+ Only file content requested by the agent is sent through Hatcher for model
43
+ processing.
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ npm install
49
+ npm run build --workspace=hatcher-connect
50
+ npm test --workspace=hatcher-connect
51
+ node apps/workspace-connector/dist/index.js --help
52
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,824 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/index.ts
27
+ var import_commander = require("commander");
28
+
29
+ // src/api.ts
30
+ var ApiError = class extends Error {
31
+ constructor(status, message, code) {
32
+ super(message);
33
+ this.status = status;
34
+ this.code = code;
35
+ this.name = "ApiError";
36
+ }
37
+ status;
38
+ code;
39
+ };
40
+ var ApiClient = class {
41
+ constructor(config) {
42
+ this.config = config;
43
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
44
+ }
45
+ config;
46
+ baseUrl;
47
+ async request(path) {
48
+ const response = await fetch(`${this.baseUrl}/api/v1${path}`, {
49
+ headers: {
50
+ Authorization: `Bearer ${this.config.apiKey}`,
51
+ Accept: "application/json"
52
+ }
53
+ });
54
+ let body;
55
+ try {
56
+ body = await response.json();
57
+ } catch {
58
+ throw new ApiError(
59
+ response.status,
60
+ `Unexpected response from Hatcher (HTTP ${response.status})`
61
+ );
62
+ }
63
+ if (!response.ok || !body.success) {
64
+ const failure = body;
65
+ throw new ApiError(response.status, failure.error || `HTTP ${response.status}`, failure.code);
66
+ }
67
+ return body.data;
68
+ }
69
+ getAccount() {
70
+ return this.request("/me");
71
+ }
72
+ getAgent(id) {
73
+ return this.request(`/agents/${encodeURIComponent(id)}`);
74
+ }
75
+ async listAgents() {
76
+ const response = await this.request("/agents?limit=100");
77
+ return response.agents;
78
+ }
79
+ };
80
+
81
+ // src/config.ts
82
+ var import_node_fs = require("fs");
83
+ var import_node_os = require("os");
84
+ var import_node_path = require("path");
85
+ var DEFAULT_BASE_URL = "https://api.hatcher.host";
86
+ var CONFIG_DIRECTORY = (0, import_node_path.join)((0, import_node_os.homedir)(), ".hatcher-connect");
87
+ var CONFIG_FILE = (0, import_node_path.join)(CONFIG_DIRECTORY, "config.json");
88
+ function normalizeBaseUrl(value) {
89
+ const url = new URL(value?.trim() || DEFAULT_BASE_URL);
90
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
91
+ throw new Error("Hatcher API URL must use http or https");
92
+ }
93
+ url.pathname = url.pathname.replace(/\/+$/, "");
94
+ url.search = "";
95
+ url.hash = "";
96
+ return url.toString().replace(/\/$/, "");
97
+ }
98
+ function loadStoredConfig() {
99
+ if (!(0, import_node_fs.existsSync)(CONFIG_FILE)) return null;
100
+ try {
101
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(CONFIG_FILE, "utf8"));
102
+ if (!parsed.apiKey?.startsWith("hk_")) return null;
103
+ return { apiKey: parsed.apiKey, baseUrl: normalizeBaseUrl(parsed.baseUrl) };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+ function loadConfig() {
109
+ const stored = loadStoredConfig();
110
+ const environmentKey = process.env.HATCHER_API_KEY?.trim();
111
+ if (environmentKey) {
112
+ return {
113
+ apiKey: environmentKey,
114
+ baseUrl: normalizeBaseUrl(process.env.HATCHER_API_URL ?? stored?.baseUrl)
115
+ };
116
+ }
117
+ return stored;
118
+ }
119
+ function saveConfig(config) {
120
+ (0, import_node_fs.mkdirSync)(CONFIG_DIRECTORY, { recursive: true, mode: 448 });
121
+ (0, import_node_fs.writeFileSync)(
122
+ CONFIG_FILE,
123
+ `${JSON.stringify({ ...config, baseUrl: normalizeBaseUrl(config.baseUrl) }, null, 2)}
124
+ `,
125
+ { mode: 384 }
126
+ );
127
+ }
128
+ function removeConfig() {
129
+ if (!(0, import_node_fs.existsSync)(CONFIG_FILE)) return false;
130
+ (0, import_node_fs.unlinkSync)(CONFIG_FILE);
131
+ return true;
132
+ }
133
+ function requireConfig() {
134
+ const config = loadConfig();
135
+ if (!config) {
136
+ throw new Error("Not connected to Hatcher. Run `hatcher-connect login` first.");
137
+ }
138
+ if (!config.apiKey.startsWith("hk_")) {
139
+ throw new Error("HATCHER_API_KEY must start with hk_");
140
+ }
141
+ return config;
142
+ }
143
+
144
+ // src/commands/agents.ts
145
+ function pad(value, width) {
146
+ return value.length >= width ? value.slice(0, width) : value.padEnd(width);
147
+ }
148
+ function registerAgentCommands(program2) {
149
+ program2.command("agents").description("List agents available to connect").action(async () => {
150
+ const agents = await new ApiClient(requireConfig()).listAgents();
151
+ if (agents.length === 0) {
152
+ console.log("No agents found in this Hatcher account.");
153
+ return;
154
+ }
155
+ console.log(`${pad("NAME", 25)} ${pad("FRAMEWORK", 12)} ${pad("STATUS", 10)} ID`);
156
+ for (const agent of agents) {
157
+ console.log(
158
+ `${pad(agent.name, 25)} ${pad(agent.framework, 12)} ${pad(agent.status, 10)} ${agent.id}`
159
+ );
160
+ }
161
+ });
162
+ }
163
+
164
+ // src/commands/auth.ts
165
+ var import_promises = require("readline/promises");
166
+ var import_node_process = require("process");
167
+ async function promptHidden(question) {
168
+ if (!import_node_process.stdin.isTTY || !import_node_process.stdout.isTTY || typeof import_node_process.stdin.setRawMode !== "function") {
169
+ const readline = (0, import_promises.createInterface)({ input: import_node_process.stdin, output: import_node_process.stdout });
170
+ try {
171
+ return (await readline.question(question)).trim();
172
+ } finally {
173
+ readline.close();
174
+ }
175
+ }
176
+ import_node_process.stdout.write(question);
177
+ const previousRawMode = import_node_process.stdin.isRaw;
178
+ import_node_process.stdin.setRawMode(true);
179
+ import_node_process.stdin.resume();
180
+ import_node_process.stdin.setEncoding("utf8");
181
+ return new Promise((resolve2, reject) => {
182
+ let value = "";
183
+ const cleanup = () => {
184
+ import_node_process.stdin.removeListener("data", onData);
185
+ import_node_process.stdin.setRawMode(previousRawMode);
186
+ import_node_process.stdin.pause();
187
+ };
188
+ const onData = (chunk) => {
189
+ for (const character of chunk) {
190
+ if (character === "") {
191
+ cleanup();
192
+ import_node_process.stdout.write("\n");
193
+ reject(new Error("Login cancelled"));
194
+ return;
195
+ }
196
+ if (character === "\r" || character === "\n") {
197
+ cleanup();
198
+ import_node_process.stdout.write("\n");
199
+ resolve2(value.trim());
200
+ return;
201
+ }
202
+ if (character === "\x7F" || character === "\b") {
203
+ if (value.length > 0) {
204
+ value = value.slice(0, -1);
205
+ import_node_process.stdout.write("\b \b");
206
+ }
207
+ continue;
208
+ }
209
+ if (character >= " ") {
210
+ value += character;
211
+ import_node_process.stdout.write("*");
212
+ }
213
+ }
214
+ };
215
+ import_node_process.stdin.on("data", onData);
216
+ });
217
+ }
218
+ function registerAuthCommands(program2) {
219
+ program2.command("login").description("Authenticate the workspace connector with a Hatcher API key").option("--key <apiKey>", "Hatcher API key (or use HATCHER_API_KEY)").option("--url <baseUrl>", "Hatcher API URL", process.env.HATCHER_API_URL ?? DEFAULT_BASE_URL).action(async (options) => {
220
+ const apiKey = options.key?.trim() || process.env.HATCHER_API_KEY?.trim() || await promptHidden("Hatcher API key: ");
221
+ if (!apiKey.startsWith("hk_")) throw new Error("Hatcher API keys must start with hk_");
222
+ const config = { apiKey, baseUrl: options.url };
223
+ const account = await new ApiClient(config).getAccount();
224
+ saveConfig(config);
225
+ console.log(`Authenticated Hatcher account ${account.id}.`);
226
+ console.log(`Credentials saved to ${CONFIG_FILE}.`);
227
+ });
228
+ program2.command("logout").description("Remove the workspace connector credentials").action(
229
+ () => console.log(removeConfig() ? "Workspace connector logged out." : "No saved credentials.")
230
+ );
231
+ program2.command("whoami").description("Verify the current workspace connector credentials").action(async () => {
232
+ const config = loadConfig();
233
+ if (!config) throw new Error("Not connected to Hatcher. Run `hatcher-connect login` first.");
234
+ const account = await new ApiClient(config).getAccount();
235
+ console.log(`Account: ${account.id}`);
236
+ console.log(`API: ${config.baseUrl}`);
237
+ });
238
+ }
239
+
240
+ // src/commands/connect.ts
241
+ var import_node_path3 = require("path");
242
+ var import_promises3 = require("timers/promises");
243
+ var import_ws = __toESM(require("ws"));
244
+
245
+ // src/local-workspace.ts
246
+ var import_node_crypto = require("crypto");
247
+ var import_promises2 = require("fs/promises");
248
+ var import_node_os2 = require("os");
249
+ var import_node_path2 = require("path");
250
+ var META_DIRECTORY = ".hatcher";
251
+ var MAX_LIST_ENTRIES = 1e3;
252
+ var MAX_SEARCH_FILES = 1e3;
253
+ var MAX_SEARCH_RESULTS = 100;
254
+ var MAX_TEXT_FILE_BYTES = 512 * 1024;
255
+ var MAX_WRITE_BYTES = 45 * 1024;
256
+ var WorkspaceError = class extends Error {
257
+ constructor(message, code = "VALIDATION_ERROR") {
258
+ super(message);
259
+ this.code = code;
260
+ this.name = "WorkspaceError";
261
+ }
262
+ code;
263
+ };
264
+ function asRecord(value) {
265
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
266
+ throw new WorkspaceError("Tool arguments must be an object");
267
+ }
268
+ return value;
269
+ }
270
+ function requiredString(input, name, max = 4096) {
271
+ const value = input[name];
272
+ if (typeof value !== "string" || !value.trim()) {
273
+ throw new WorkspaceError(`${name} must be a non-empty string`);
274
+ }
275
+ if (value.length > max) throw new WorkspaceError(`${name} is too long`);
276
+ return value;
277
+ }
278
+ function requiredContent(input, name, max) {
279
+ const value = input[name];
280
+ if (typeof value !== "string") throw new WorkspaceError(`${name} must be a string`);
281
+ if (value.length > max) throw new WorkspaceError(`${name} is too long`);
282
+ return value;
283
+ }
284
+ function optionalString(input, name) {
285
+ const value = input[name];
286
+ if (value === void 0) return void 0;
287
+ if (typeof value !== "string") throw new WorkspaceError(`${name} must be a string`);
288
+ return value;
289
+ }
290
+ function normalizeRelativePath(raw, allowRoot) {
291
+ const value = (raw ?? ".").trim().replaceAll("\\", "/");
292
+ if (value.includes("\0")) throw new WorkspaceError("Paths must not contain null bytes");
293
+ if ((0, import_node_path2.isAbsolute)(value) || import_node_path2.win32.isAbsolute(value)) {
294
+ throw new WorkspaceError(
295
+ "Only paths relative to the connected workspace are allowed",
296
+ "FORBIDDEN"
297
+ );
298
+ }
299
+ const normalized = import_node_path2.posix.normalize(value || ".");
300
+ if (normalized === ".." || normalized.startsWith("../")) {
301
+ throw new WorkspaceError("Path traversal outside the workspace is not allowed", "FORBIDDEN");
302
+ }
303
+ if (!allowRoot && normalized === ".")
304
+ throw new WorkspaceError("A file or directory path is required");
305
+ if (normalized === META_DIRECTORY || normalized.startsWith(`${META_DIRECTORY}/`)) {
306
+ throw new WorkspaceError("The .hatcher metadata directory is reserved", "FORBIDDEN");
307
+ }
308
+ return normalized;
309
+ }
310
+ function isWithinRoot(root, candidate) {
311
+ const rel = (0, import_node_path2.relative)(root, candidate);
312
+ return rel === "" || !rel.startsWith(`..${import_node_path2.sep}`) && rel !== ".." && !(0, import_node_path2.isAbsolute)(rel);
313
+ }
314
+ async function pathExists(path) {
315
+ try {
316
+ await (0, import_promises2.lstat)(path);
317
+ return true;
318
+ } catch (error) {
319
+ if (error.code === "ENOENT") return false;
320
+ throw error;
321
+ }
322
+ }
323
+ async function assertNoSymlink(root, relativePath, allowMissingLeaf) {
324
+ if (relativePath === ".") return;
325
+ const segments = relativePath.split("/");
326
+ let current = root;
327
+ for (let index = 0; index < segments.length; index += 1) {
328
+ current = (0, import_node_path2.join)(current, segments[index]);
329
+ try {
330
+ const info = await (0, import_promises2.lstat)(current);
331
+ if (info.isSymbolicLink()) {
332
+ throw new WorkspaceError(
333
+ "Symbolic links are not accessible through a local workspace",
334
+ "FORBIDDEN"
335
+ );
336
+ }
337
+ } catch (error) {
338
+ if (error.code === "ENOENT" && allowMissingLeaf) return;
339
+ throw error;
340
+ }
341
+ }
342
+ }
343
+ async function resolveWorkspacePath(root, raw, options = {}) {
344
+ const normalized = normalizeRelativePath(raw, options.allowRoot ?? false);
345
+ const absolute = (0, import_node_path2.resolve)(root, ...normalized.split("/"));
346
+ if (!isWithinRoot(root, absolute)) {
347
+ throw new WorkspaceError("Path escapes the connected workspace", "FORBIDDEN");
348
+ }
349
+ await assertNoSymlink(root, normalized, options.allowMissing ?? false);
350
+ if (!options.allowMissing && !await pathExists(absolute)) {
351
+ throw new WorkspaceError(`Path not found: ${normalized}`, "NOT_FOUND");
352
+ }
353
+ return { absolute, relative: normalized };
354
+ }
355
+ function portablePath(root, absolute) {
356
+ const value = (0, import_node_path2.relative)(root, absolute).split(import_node_path2.sep).join("/");
357
+ return value || ".";
358
+ }
359
+ function sha256(content) {
360
+ return (0, import_node_crypto.createHash)("sha256").update(content).digest("hex");
361
+ }
362
+ function timestampDirectory() {
363
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
364
+ }
365
+ function isProbablyBinary(content) {
366
+ return content.subarray(0, Math.min(content.length, 8192)).includes(0);
367
+ }
368
+ async function listWorkspace(root, args) {
369
+ const requested = optionalString(args, "path");
370
+ const recursive = args.recursive === true;
371
+ const target = await resolveWorkspacePath(root, requested, { allowRoot: true });
372
+ const targetInfo = await (0, import_promises2.stat)(target.absolute);
373
+ if (!targetInfo.isDirectory()) throw new WorkspaceError(`${target.relative} is not a directory`);
374
+ const entries = [];
375
+ const queue = [target.absolute];
376
+ while (queue.length > 0 && entries.length < MAX_LIST_ENTRIES) {
377
+ const current = queue.shift();
378
+ const children = await import("fs/promises").then(
379
+ ({ readdir }) => readdir(current, { withFileTypes: true })
380
+ );
381
+ children.sort((left, right) => left.name.localeCompare(right.name));
382
+ for (const child of children) {
383
+ if (current === root && child.name === META_DIRECTORY) continue;
384
+ const absolute = (0, import_node_path2.join)(current, child.name);
385
+ const info = await (0, import_promises2.lstat)(absolute);
386
+ const type = info.isSymbolicLink() ? "symlink" : info.isDirectory() ? "directory" : "file";
387
+ entries.push({
388
+ path: portablePath(root, absolute),
389
+ type,
390
+ size: info.isFile() ? info.size : 0,
391
+ modifiedAt: info.mtime.toISOString()
392
+ });
393
+ if (entries.length >= MAX_LIST_ENTRIES) break;
394
+ if (recursive && type === "directory") queue.push(absolute);
395
+ }
396
+ }
397
+ return { path: target.relative, entries, truncated: entries.length >= MAX_LIST_ENTRIES };
398
+ }
399
+ async function readWorkspaceFile(root, args) {
400
+ const requested = requiredString(args, "path");
401
+ const target = await resolveWorkspacePath(root, requested);
402
+ const info = await (0, import_promises2.stat)(target.absolute);
403
+ if (!info.isFile()) throw new WorkspaceError(`${target.relative} is not a file`);
404
+ if (info.size > MAX_TEXT_FILE_BYTES) {
405
+ throw new WorkspaceError(`File is larger than ${MAX_TEXT_FILE_BYTES} bytes`);
406
+ }
407
+ const content = await (0, import_promises2.readFile)(target.absolute);
408
+ if (isProbablyBinary(content)) throw new WorkspaceError("Binary files are not supported");
409
+ return {
410
+ path: target.relative,
411
+ content: content.toString("utf8"),
412
+ sha256: sha256(content),
413
+ size: content.length,
414
+ modifiedAt: info.mtime.toISOString()
415
+ };
416
+ }
417
+ async function writeWorkspaceFile(root, args) {
418
+ const requested = requiredString(args, "path");
419
+ const content = requiredContent(args, "content", MAX_WRITE_BYTES);
420
+ const expectedSha256 = optionalString(args, "expectedSha256");
421
+ const target = await resolveWorkspacePath(root, requested, { allowMissing: true });
422
+ const parent = await resolveWorkspacePath(root, portablePath(root, (0, import_node_path2.dirname)(target.absolute)), {
423
+ allowRoot: true
424
+ });
425
+ const parentInfo = await (0, import_promises2.stat)(parent.absolute).catch(() => null);
426
+ if (!parentInfo?.isDirectory())
427
+ throw new WorkspaceError("Parent directory does not exist", "NOT_FOUND");
428
+ const exists = await pathExists(target.absolute);
429
+ let previousSha256 = null;
430
+ if (exists) {
431
+ const targetInfo = await (0, import_promises2.lstat)(target.absolute);
432
+ if (!targetInfo.isFile()) throw new WorkspaceError("Only regular files can be replaced");
433
+ const previous = await (0, import_promises2.readFile)(target.absolute);
434
+ previousSha256 = sha256(previous);
435
+ if (!expectedSha256) {
436
+ throw new WorkspaceError(
437
+ "expectedSha256 is required when replacing an existing file",
438
+ "CONFLICT"
439
+ );
440
+ }
441
+ if (expectedSha256 !== previousSha256) {
442
+ throw new WorkspaceError(
443
+ "File changed after it was read; read it again before writing",
444
+ "CONFLICT"
445
+ );
446
+ }
447
+ const historyPath = (0, import_node_path2.join)(
448
+ root,
449
+ META_DIRECTORY,
450
+ "history",
451
+ timestampDirectory(),
452
+ ...target.relative.split("/")
453
+ );
454
+ await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(historyPath), { recursive: true });
455
+ await (0, import_promises2.writeFile)(historyPath, previous);
456
+ } else if (expectedSha256) {
457
+ throw new WorkspaceError("File no longer exists; omit expectedSha256 to create it", "CONFLICT");
458
+ }
459
+ const next = Buffer.from(content, "utf8");
460
+ if (next.length > MAX_WRITE_BYTES)
461
+ throw new WorkspaceError(`Content is larger than ${MAX_WRITE_BYTES} bytes`);
462
+ const temporary = (0, import_node_path2.join)(
463
+ (0, import_node_path2.dirname)(target.absolute),
464
+ `.${(0, import_node_path2.basename)(target.absolute)}.hatcher-${(0, import_node_crypto.randomUUID)()}.tmp`
465
+ );
466
+ await (0, import_promises2.writeFile)(temporary, next, { flag: "wx" });
467
+ try {
468
+ await (0, import_promises2.rename)(temporary, target.absolute);
469
+ } catch (error) {
470
+ await import("fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => void 0);
471
+ throw error;
472
+ }
473
+ return {
474
+ path: target.relative,
475
+ created: !exists,
476
+ previousSha256,
477
+ sha256: sha256(next),
478
+ size: next.length
479
+ };
480
+ }
481
+ async function createWorkspaceDirectory(root, args) {
482
+ const requested = requiredString(args, "path");
483
+ const target = await resolveWorkspacePath(root, requested, { allowMissing: true });
484
+ await (0, import_promises2.mkdir)(target.absolute, { recursive: true });
485
+ await assertNoSymlink(root, target.relative, false);
486
+ return { path: target.relative, created: true };
487
+ }
488
+ async function moveWorkspacePath(root, args) {
489
+ const source = await resolveWorkspacePath(root, requiredString(args, "from"));
490
+ const destination = await resolveWorkspacePath(root, requiredString(args, "to"), {
491
+ allowMissing: true
492
+ });
493
+ if (await pathExists(destination.absolute))
494
+ throw new WorkspaceError("Destination already exists", "CONFLICT");
495
+ const destinationParent = await resolveWorkspacePath(
496
+ root,
497
+ portablePath(root, (0, import_node_path2.dirname)(destination.absolute)),
498
+ { allowRoot: true }
499
+ );
500
+ if (!(await (0, import_promises2.stat)(destinationParent.absolute)).isDirectory()) {
501
+ throw new WorkspaceError("Destination parent is not a directory");
502
+ }
503
+ await (0, import_promises2.rename)(source.absolute, destination.absolute);
504
+ return { from: source.relative, to: destination.relative };
505
+ }
506
+ async function deleteWorkspacePath(root, args) {
507
+ const target = await resolveWorkspacePath(root, requiredString(args, "path"));
508
+ const trashRelative = (0, import_node_path2.join)("trash", timestampDirectory(), ...target.relative.split("/"));
509
+ const trashAbsolute = (0, import_node_path2.join)(root, META_DIRECTORY, trashRelative);
510
+ await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(trashAbsolute), { recursive: true });
511
+ await (0, import_promises2.rename)(target.absolute, trashAbsolute);
512
+ return {
513
+ path: target.relative,
514
+ recoverable: true,
515
+ trashPath: `${META_DIRECTORY}/${trashRelative.split(import_node_path2.sep).join("/")}`
516
+ };
517
+ }
518
+ async function searchWorkspace(root, args) {
519
+ const query = requiredString(args, "query", 256);
520
+ const caseSensitive = args.caseSensitive === true;
521
+ const requested = optionalString(args, "path");
522
+ const target = await resolveWorkspacePath(root, requested, { allowRoot: true });
523
+ if (!(await (0, import_promises2.stat)(target.absolute)).isDirectory())
524
+ throw new WorkspaceError(`${target.relative} is not a directory`);
525
+ const needle = caseSensitive ? query : query.toLocaleLowerCase();
526
+ const matches = [];
527
+ const queue = [target.absolute];
528
+ let scannedFiles = 0;
529
+ while (queue.length > 0 && scannedFiles < MAX_SEARCH_FILES && matches.length < MAX_SEARCH_RESULTS) {
530
+ const current = queue.shift();
531
+ const children = await import("fs/promises").then(
532
+ ({ readdir }) => readdir(current, { withFileTypes: true })
533
+ );
534
+ for (const child of children) {
535
+ if (current === root && child.name === META_DIRECTORY) continue;
536
+ const absolute = (0, import_node_path2.join)(current, child.name);
537
+ const info = await (0, import_promises2.lstat)(absolute);
538
+ if (info.isSymbolicLink()) continue;
539
+ if (info.isDirectory()) {
540
+ queue.push(absolute);
541
+ continue;
542
+ }
543
+ if (!info.isFile() || info.size > MAX_TEXT_FILE_BYTES) continue;
544
+ scannedFiles += 1;
545
+ const content = await (0, import_promises2.readFile)(absolute);
546
+ if (isProbablyBinary(content)) continue;
547
+ const lines = content.toString("utf8").split(/\r?\n/);
548
+ for (let index = 0; index < lines.length; index += 1) {
549
+ const haystack = caseSensitive ? lines[index] : lines[index].toLocaleLowerCase();
550
+ if (haystack.includes(needle)) {
551
+ matches.push({
552
+ path: portablePath(root, absolute),
553
+ line: index + 1,
554
+ text: lines[index].slice(0, 500)
555
+ });
556
+ if (matches.length >= MAX_SEARCH_RESULTS) break;
557
+ }
558
+ }
559
+ }
560
+ }
561
+ return {
562
+ query,
563
+ matches,
564
+ scannedFiles,
565
+ truncated: scannedFiles >= MAX_SEARCH_FILES || matches.length >= MAX_SEARCH_RESULTS
566
+ };
567
+ }
568
+ async function prepareWorkspaceRoot(input) {
569
+ const absolute = (0, import_node_path2.resolve)(input);
570
+ await (0, import_promises2.mkdir)(absolute, { recursive: true });
571
+ const canonical = await (0, import_promises2.realpath)(absolute);
572
+ const canonicalHome = await (0, import_promises2.realpath)((0, import_node_os2.homedir)()).catch(() => (0, import_node_path2.resolve)((0, import_node_os2.homedir)()));
573
+ if (canonical === (0, import_node_path2.parse)(canonical).root || canonical === canonicalHome) {
574
+ throw new WorkspaceError(
575
+ "Connect a dedicated subfolder, not a filesystem root or your home directory",
576
+ "FORBIDDEN"
577
+ );
578
+ }
579
+ const info = await (0, import_promises2.stat)(canonical);
580
+ if (!info.isDirectory()) throw new WorkspaceError("Workspace path must be a directory");
581
+ await (0, import_promises2.mkdir)((0, import_node_path2.join)(canonical, META_DIRECTORY), { recursive: true });
582
+ return canonical;
583
+ }
584
+ async function executeWorkspaceTool(root, tool, rawArguments, readOnly) {
585
+ const args = asRecord(rawArguments);
586
+ if (readOnly && !["workspace.list", "workspace.search", "workspace.read"].includes(tool)) {
587
+ throw new WorkspaceError("This local workspace was connected in read-only mode", "FORBIDDEN");
588
+ }
589
+ switch (tool) {
590
+ case "workspace.list":
591
+ return listWorkspace(root, args);
592
+ case "workspace.search":
593
+ return searchWorkspace(root, args);
594
+ case "workspace.read":
595
+ return readWorkspaceFile(root, args);
596
+ case "workspace.write":
597
+ return writeWorkspaceFile(root, args);
598
+ case "workspace.mkdir":
599
+ return createWorkspaceDirectory(root, args);
600
+ case "workspace.move":
601
+ return moveWorkspacePath(root, args);
602
+ case "workspace.delete":
603
+ return deleteWorkspacePath(root, args);
604
+ }
605
+ }
606
+
607
+ // src/commands/connect.ts
608
+ var PROTOCOL_VERSION = 1;
609
+ var VALID_TOOLS = /* @__PURE__ */ new Set([
610
+ "workspace.list",
611
+ "workspace.search",
612
+ "workspace.read",
613
+ "workspace.write",
614
+ "workspace.mkdir",
615
+ "workspace.move",
616
+ "workspace.delete"
617
+ ]);
618
+ var ConnectionError = class extends Error {
619
+ constructor(message, retryable) {
620
+ super(message);
621
+ this.retryable = retryable;
622
+ this.name = "ConnectionError";
623
+ }
624
+ retryable;
625
+ };
626
+ function websocketUrl(baseUrl, agentId) {
627
+ const url = new URL(
628
+ `/integrations/local-workspace/agents/${encodeURIComponent(agentId)}/ws`,
629
+ baseUrl
630
+ );
631
+ if (url.protocol === "https:") url.protocol = "wss:";
632
+ else if (url.protocol === "http:") url.protocol = "ws:";
633
+ else throw new Error("Hatcher API URL must use http or https");
634
+ return url.toString();
635
+ }
636
+ function parseRequest(raw) {
637
+ let payload;
638
+ try {
639
+ payload = JSON.parse(raw.toString());
640
+ } catch {
641
+ return null;
642
+ }
643
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
644
+ const frame = payload;
645
+ if (frame.type !== "request" || typeof frame.id !== "string" || typeof frame.tool !== "string")
646
+ return null;
647
+ if (!VALID_TOOLS.has(frame.tool)) return null;
648
+ return {
649
+ type: "request",
650
+ id: frame.id,
651
+ tool: frame.tool,
652
+ arguments: frame.arguments ?? {}
653
+ };
654
+ }
655
+ async function serveConnection(input) {
656
+ await new Promise((resolve2, reject) => {
657
+ const socket = new import_ws.default(input.url, {
658
+ headers: { Authorization: `Bearer ${input.apiKey}` },
659
+ maxPayload: 1024 * 1024
660
+ });
661
+ let authenticated = false;
662
+ let settled = false;
663
+ let operationChain = Promise.resolve();
664
+ const keepAlive = setInterval(() => {
665
+ if (socket.readyState === import_ws.default.OPEN) socket.ping();
666
+ }, 25e3);
667
+ keepAlive.unref?.();
668
+ socket.on("open", () => {
669
+ socket.send(
670
+ JSON.stringify({
671
+ type: "hello",
672
+ protocolVersion: PROTOCOL_VERSION,
673
+ workspaceName: (0, import_node_path3.basename)(input.root),
674
+ readOnly: input.readOnly
675
+ })
676
+ );
677
+ });
678
+ socket.on("message", (raw) => {
679
+ let control = null;
680
+ try {
681
+ const parsed = JSON.parse(raw.toString());
682
+ control = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
683
+ } catch {
684
+ control = null;
685
+ }
686
+ if (control?.type === "ready") {
687
+ authenticated = true;
688
+ console.log(`Connected to agent ${input.agentId}. Press Ctrl+C to disconnect.`);
689
+ return;
690
+ }
691
+ if (control?.type === "error" && typeof control.error === "string") {
692
+ if (!authenticated) reject(new ConnectionError(control.error, false));
693
+ else console.error(`Hatcher: ${control.error}`);
694
+ socket.close();
695
+ return;
696
+ }
697
+ const request = parseRequest(raw);
698
+ if (!request) return;
699
+ operationChain = operationChain.then(async () => {
700
+ try {
701
+ const result = await executeWorkspaceTool(
702
+ input.root,
703
+ request.tool,
704
+ request.arguments,
705
+ input.readOnly
706
+ );
707
+ if (socket.readyState === import_ws.default.OPEN) {
708
+ socket.send(JSON.stringify({ type: "result", id: request.id, result }));
709
+ }
710
+ } catch (error) {
711
+ const workspaceError = error instanceof WorkspaceError ? error : new WorkspaceError(error instanceof Error ? error.message : String(error));
712
+ if (socket.readyState === import_ws.default.OPEN) {
713
+ socket.send(
714
+ JSON.stringify({
715
+ type: "error",
716
+ id: request.id,
717
+ error: workspaceError.message,
718
+ code: workspaceError.code
719
+ })
720
+ );
721
+ }
722
+ }
723
+ });
724
+ });
725
+ socket.on("error", (error) => {
726
+ if (!authenticated && !settled) {
727
+ settled = true;
728
+ clearInterval(keepAlive);
729
+ reject(new ConnectionError(error.message, true));
730
+ }
731
+ });
732
+ socket.on("close", (code, reason) => {
733
+ clearInterval(keepAlive);
734
+ if (settled) return;
735
+ settled = true;
736
+ if (!input.stopped() && (code === 1008 || code === 4009)) {
737
+ reject(new ConnectionError(reason.toString() || `Connection closed (${code})`, false));
738
+ } else if (!authenticated && !input.stopped()) {
739
+ reject(new ConnectionError(reason.toString() || `Connection closed (${code})`, true));
740
+ } else {
741
+ resolve2();
742
+ }
743
+ });
744
+ const close = () => {
745
+ if (socket.readyState === import_ws.default.OPEN || socket.readyState === import_ws.default.CONNECTING) {
746
+ socket.close(1e3, "User disconnected");
747
+ }
748
+ };
749
+ process.once("SIGINT", close);
750
+ process.once("SIGTERM", close);
751
+ socket.once("close", () => {
752
+ process.removeListener("SIGINT", close);
753
+ process.removeListener("SIGTERM", close);
754
+ });
755
+ });
756
+ }
757
+ function registerConnectCommand(program2) {
758
+ program2.command("connect").description("Connect a dedicated local folder to one Hatcher agent").requiredOption("--agent <id>", "Agent ID that can access the folder").requiredOption("--folder <path>", "Dedicated local folder to connect").option("--read-only", "Allow listing, searching, and reading only").action(async (options) => {
759
+ const config = requireConfig();
760
+ const client = new ApiClient(config);
761
+ const root = await prepareWorkspaceRoot(options.folder);
762
+ const agent = await client.getAgent(options.agent);
763
+ const readOnly = options.readOnly === true;
764
+ console.log(`Local folder: ${root}`);
765
+ console.log(`Agent: ${agent.name} (${agent.id})`);
766
+ console.log(
767
+ `Access: ${readOnly ? "read only" : "read, create, edit, move, and recoverable delete"}`
768
+ );
769
+ console.log("Only content requested by the agent is sent to Hatcher for processing.");
770
+ let stopped = false;
771
+ const reconnectAbort = new AbortController();
772
+ const stop = () => {
773
+ stopped = true;
774
+ reconnectAbort.abort();
775
+ };
776
+ process.once("SIGINT", stop);
777
+ process.once("SIGTERM", stop);
778
+ let attempt = 0;
779
+ try {
780
+ while (!stopped) {
781
+ try {
782
+ await serveConnection({
783
+ url: websocketUrl(config.baseUrl, agent.id),
784
+ apiKey: config.apiKey,
785
+ agentId: agent.id,
786
+ root,
787
+ readOnly,
788
+ stopped: () => stopped
789
+ });
790
+ if (stopped) break;
791
+ } catch (error) {
792
+ if (error instanceof ConnectionError && !error.retryable) throw error;
793
+ if (attempt === 0)
794
+ console.error(
795
+ `Connection failed: ${error instanceof Error ? error.message : String(error)}`
796
+ );
797
+ }
798
+ attempt += 1;
799
+ const waitMs = Math.min(3e4, 1e3 * 2 ** Math.min(attempt, 5));
800
+ console.log(`Disconnected. Reconnecting in ${Math.round(waitMs / 1e3)}s...`);
801
+ await (0, import_promises3.setTimeout)(waitMs, void 0, { signal: reconnectAbort.signal, ref: true }).catch(
802
+ (error) => {
803
+ if (!stopped) throw error;
804
+ }
805
+ );
806
+ }
807
+ } finally {
808
+ process.removeListener("SIGINT", stop);
809
+ process.removeListener("SIGTERM", stop);
810
+ }
811
+ console.log("Local workspace disconnected.");
812
+ });
813
+ }
814
+
815
+ // src/index.ts
816
+ var program = new import_commander.Command();
817
+ program.name("hatcher-connect").description("Connect a dedicated local folder to a Hatcher agent").version("0.1.0");
818
+ registerAuthCommands(program);
819
+ registerAgentCommands(program);
820
+ registerConnectCommand(program);
821
+ program.parseAsync(process.argv).catch((error) => {
822
+ console.error(error instanceof Error ? error.message : String(error));
823
+ process.exitCode = 1;
824
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "hatcher-connect",
3
+ "version": "0.1.0",
4
+ "description": "Standalone local workspace connector for Hatcher agents",
5
+ "bin": {
6
+ "hatcher-connect": "dist/index.js"
7
+ },
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup src/index.ts --format cjs --clean",
14
+ "prepack": "npm run build",
15
+ "test": "vitest run --config vitest.config.ts",
16
+ "type-check": "tsc --noEmit"
17
+ },
18
+ "keywords": [
19
+ "hatcher",
20
+ "agent",
21
+ "workspace",
22
+ "connector"
23
+ ],
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "commander": "^12.0.0",
27
+ "ws": "^8.18.3"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "@types/ws": "^8.18.1",
32
+ "tsup": "^8.0.0",
33
+ "typescript": "^5.7.0",
34
+ "vitest": "^4.1.11"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/HatcherLabs/Hatcher.git",
39
+ "directory": "apps/workspace-connector"
40
+ },
41
+ "homepage": "https://hatcher.host",
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ },
46
+ "engines": {
47
+ "node": ">=20.0.0"
48
+ }
49
+ }