@panphora/clay-agent 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/bin/clay-agent.js +9 -0
- package/package.json +50 -4
- package/src/actions.js +288 -0
- package/src/cli.js +521 -0
- package/src/credentials.js +137 -0
- package/src/discovery.js +76 -0
- package/src/errors.js +90 -0
- package/src/hosts/hyperclay-adapter.js +85 -0
- package/src/hosts/hyperclay.js +210 -0
- package/src/hosts/whoami.js +56 -0
- package/src/html/edit.js +103 -0
- package/src/html/find.js +157 -0
- package/src/html/outline.js +154 -0
- package/src/html/regex-worker.js +17 -0
- package/src/html/select.js +54 -0
- package/src/html/source.js +73 -0
- package/src/mcp.js +296 -0
- package/src/runtime.js +46 -0
- package/src/session.js +69 -0
- package/src/target.js +209 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
|
|
5
|
+
import { ClayError, asClayError, exitCode, redact } from './errors.js';
|
|
6
|
+
import { baseUrl, isAgentKey, readKey, removeKey, saveKey } from './credentials.js';
|
|
7
|
+
import { whoami } from './hosts/whoami.js';
|
|
8
|
+
import { runMcp } from './mcp.js';
|
|
9
|
+
import { buildActions } from './runtime.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const VERSION = require('../package.json').version;
|
|
13
|
+
|
|
14
|
+
const USAGE = `clay-agent lets AI agents read and edit your hyperclay.com documents.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
clay-agent login [--hyperclay-url <url>]
|
|
18
|
+
clay-agent logout [--hyperclay-url <url>]
|
|
19
|
+
clay-agent status [--hyperclay-url <url>] [--json]
|
|
20
|
+
clay-agent mcp [--read-only] [--hosts <a,b>]
|
|
21
|
+
clay-agent list [where] [--cursor <id>]
|
|
22
|
+
clay-agent read <document> [--select <css>] [--range <start:length>] [--max-chars <n>]
|
|
23
|
+
clay-agent read <document> --outline [--depth <n>]
|
|
24
|
+
clay-agent read <document> (--find <text> | --regex <pattern>) [--limit <n>]
|
|
25
|
+
clay-agent data <document>
|
|
26
|
+
clay-agent data <document> --write <json|-> --if-match <etag>
|
|
27
|
+
clay-agent edit <document> (--find <text> --replace <text> [--occurrence <n>] | --edits <file.json>) --if-match <etag>
|
|
28
|
+
clay-agent save <document> --file <path|-> --if-match <etag>
|
|
29
|
+
clay-agent versions <document> [--before <id>]
|
|
30
|
+
clay-agent version <document> <version-id> [--select <css>] [--range <start:length>]
|
|
31
|
+
clay-agent restore <document> <version-id> --if-match <etag>
|
|
32
|
+
|
|
33
|
+
Document commands take --json, --host <host> and --hyperclay-url <url>.
|
|
34
|
+
Your agent key is only read from stdin or the CLAY_AGENT_KEY environment variable.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const NOT_SIGNED_IN =
|
|
38
|
+
'Not signed in. Make an agent key in the dashboard (Account, then Agent keys), then run: npx -y @panphora/clay-agent login';
|
|
39
|
+
|
|
40
|
+
const KEY_COMMAND_LINE =
|
|
41
|
+
'Do not put the key on the command line: it would be saved in your shell history. Run `clay-agent login` and paste it.';
|
|
42
|
+
|
|
43
|
+
const KEY_IN_ARGV = /hcak_[0-9a-f]{64}/i;
|
|
44
|
+
|
|
45
|
+
const SCOPES = {
|
|
46
|
+
all: 'everything',
|
|
47
|
+
account: 'one account',
|
|
48
|
+
folder: 'one folder',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function scopeLabel(scope) {
|
|
52
|
+
return SCOPES[scope?.kind] || 'unknown scope';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseArgv(argv) {
|
|
56
|
+
const flags = { json: false, url: null };
|
|
57
|
+
const positionals = [];
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
if (arg === '--json') {
|
|
61
|
+
flags.json = true;
|
|
62
|
+
} else if (arg === '--hyperclay-url') {
|
|
63
|
+
flags.url = argv[i + 1] ?? null;
|
|
64
|
+
i += 1;
|
|
65
|
+
} else if (arg.startsWith('--hyperclay-url=')) {
|
|
66
|
+
flags.url = arg.slice('--hyperclay-url='.length);
|
|
67
|
+
} else if (arg.startsWith('-')) {
|
|
68
|
+
positionals.push(arg);
|
|
69
|
+
} else {
|
|
70
|
+
positionals.push(arg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { flags, positionals };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readAll(stream) {
|
|
77
|
+
const chunks = [];
|
|
78
|
+
for await (const chunk of stream) {
|
|
79
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
80
|
+
}
|
|
81
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readSecret(stdin, stderr) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
|
|
87
|
+
rl._writeToOutput = () => {};
|
|
88
|
+
rl.on('SIGINT', () => {
|
|
89
|
+
stderr.write('\n');
|
|
90
|
+
process.exit(130);
|
|
91
|
+
});
|
|
92
|
+
stderr.write('Paste your agent key: ');
|
|
93
|
+
let answer = '';
|
|
94
|
+
let answered = false;
|
|
95
|
+
rl.on('line', (line) => {
|
|
96
|
+
if (answered) return;
|
|
97
|
+
answered = true;
|
|
98
|
+
answer = line;
|
|
99
|
+
rl.close();
|
|
100
|
+
});
|
|
101
|
+
rl.on('close', () => {
|
|
102
|
+
stderr.write('\n');
|
|
103
|
+
resolve(answer);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readPastedKey({ stdin, stderr }) {
|
|
109
|
+
if (stdin.isTTY) return readSecret(stdin, stderr);
|
|
110
|
+
return readAll(stdin);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expiresLabel(expiresAt) {
|
|
114
|
+
if (!expiresAt) return 'never';
|
|
115
|
+
const date = new Date(expiresAt);
|
|
116
|
+
if (Number.isNaN(date.getTime())) return String(expiresAt);
|
|
117
|
+
return date.toISOString().slice(0, 10);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function login(argv, { stdin, stdout, stderr, env }) {
|
|
121
|
+
const { flags, positionals } = parseArgv(argv);
|
|
122
|
+
if (positionals.length) {
|
|
123
|
+
throw new ClayError('invalid-argument', KEY_COMMAND_LINE);
|
|
124
|
+
}
|
|
125
|
+
const base = baseUrl({ flag: flags.url, env });
|
|
126
|
+
const key = (await readPastedKey({ stdin, stderr })).trim();
|
|
127
|
+
if (!isAgentKey(key)) {
|
|
128
|
+
throw new ClayError('invalid-key', 'That is not an agent key. Copy it from the dashboard: it starts with hcak_.');
|
|
129
|
+
}
|
|
130
|
+
const info = await whoami(base, key);
|
|
131
|
+
await saveKey(base, key, env);
|
|
132
|
+
const name = info?.key?.name || 'unnamed key';
|
|
133
|
+
if (flags.json) {
|
|
134
|
+
stdout.write(`${JSON.stringify({ person: info?.person, key: info?.key }, null, 2)}\n`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
stdout.write(
|
|
138
|
+
`Signed in as ${info?.person?.username} through "${name}" (${info?.key?.access}, ${scopeLabel(info?.key?.scope)})\n`,
|
|
139
|
+
);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function logout(argv, { stdout, env }) {
|
|
144
|
+
const { flags } = parseArgv(argv);
|
|
145
|
+
const base = baseUrl({ flag: flags.url, env });
|
|
146
|
+
const removed = await removeKey(base, env);
|
|
147
|
+
if (flags.json) {
|
|
148
|
+
stdout.write(`${JSON.stringify({ removed }, null, 2)}\n`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
stdout.write(removed ? 'Signed out.\n' : 'You were not signed in.\n');
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function status(argv, { stdout, env }) {
|
|
156
|
+
const { flags } = parseArgv(argv);
|
|
157
|
+
const base = baseUrl({ flag: flags.url, env });
|
|
158
|
+
const key = await readKey(base, env);
|
|
159
|
+
if (!key) {
|
|
160
|
+
throw new ClayError('key-missing', NOT_SIGNED_IN);
|
|
161
|
+
}
|
|
162
|
+
const info = await whoami(base, key);
|
|
163
|
+
if (flags.json) {
|
|
164
|
+
stdout.write(`${JSON.stringify({ person: info.person, key: info.key }, null, 2)}\n`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
const k = info.key || {};
|
|
168
|
+
stdout.write(`Signed in as ${info.person?.username}\n`);
|
|
169
|
+
stdout.write(`Key: "${k.name}" (${k.prefix}\u2026, ${k.access}, ${scopeLabel(k.scope)})\n`);
|
|
170
|
+
stdout.write(`Expires: ${expiresLabel(k.expiresAt)}\n`);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const COMMAND_USAGE = {
|
|
175
|
+
list: 'Usage: clay-agent list [where] [--cursor <id>]',
|
|
176
|
+
read: 'Usage: clay-agent read <document> [--select <css>] [--range <start:length>] [--max-chars <n>] [--outline [--depth <n>]] [--find <text> | --regex <pattern> [--limit <n>]]',
|
|
177
|
+
data: 'Usage: clay-agent data <document> [--write <json|-> --if-match <etag>]',
|
|
178
|
+
edit: 'Usage: clay-agent edit <document> (--find <text> --replace <text> [--occurrence <n>] | --edits <file.json>) --if-match <etag>',
|
|
179
|
+
save: 'Usage: clay-agent save <document> --file <path|-> --if-match <etag>',
|
|
180
|
+
versions: 'Usage: clay-agent versions <document> [--before <id>]',
|
|
181
|
+
version: 'Usage: clay-agent version <document> <version-id> [--select <css>] [--range <start:length>]',
|
|
182
|
+
restore: 'Usage: clay-agent restore <document> <version-id> --if-match <etag>',
|
|
183
|
+
mcp: 'Usage: clay-agent mcp [--read-only] [--hosts <a,b>]',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const FLAG_NAMES = {
|
|
187
|
+
'--host': 'host',
|
|
188
|
+
'--hyperclay-url': 'hyperclayUrl',
|
|
189
|
+
'--cursor': 'cursor',
|
|
190
|
+
'--select': 'select',
|
|
191
|
+
'--range': 'range',
|
|
192
|
+
'--max-chars': 'maxChars',
|
|
193
|
+
'--depth': 'depth',
|
|
194
|
+
'--find': 'find',
|
|
195
|
+
'--regex': 'regex',
|
|
196
|
+
'--limit': 'limit',
|
|
197
|
+
'--write': 'write',
|
|
198
|
+
'--if-match': 'ifMatch',
|
|
199
|
+
'--replace': 'replace',
|
|
200
|
+
'--occurrence': 'occurrence',
|
|
201
|
+
'--edits': 'edits',
|
|
202
|
+
'--file': 'file',
|
|
203
|
+
'--before': 'before',
|
|
204
|
+
'--hosts': 'hosts',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const BOOLEAN_FLAGS = {
|
|
208
|
+
'--json': 'json',
|
|
209
|
+
'--outline': 'outline',
|
|
210
|
+
'--read-only': 'readOnly',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function usageFailure(usage) {
|
|
214
|
+
return new ClayError('invalid-argument', usage, { details: { usage } });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseCommandArgv(argv, usage) {
|
|
218
|
+
const flags = {};
|
|
219
|
+
const positionals = [];
|
|
220
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
221
|
+
const arg = argv[i];
|
|
222
|
+
const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
223
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
224
|
+
const inline = equals === -1 ? undefined : arg.slice(equals + 1);
|
|
225
|
+
if (name in BOOLEAN_FLAGS) {
|
|
226
|
+
flags[BOOLEAN_FLAGS[name]] = true;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (name in FLAG_NAMES) {
|
|
230
|
+
let value = inline;
|
|
231
|
+
if (value === undefined) {
|
|
232
|
+
value = argv[i + 1];
|
|
233
|
+
if (value === undefined) throw usageFailure(usage);
|
|
234
|
+
i += 1;
|
|
235
|
+
}
|
|
236
|
+
flags[FLAG_NAMES[name]] = value;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (arg.startsWith('-') && arg !== '-') throw usageFailure(usage);
|
|
240
|
+
positionals.push(arg);
|
|
241
|
+
}
|
|
242
|
+
return { flags, positionals };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function requirePositionals(positionals, count, usage) {
|
|
246
|
+
if (positionals.length < count) throw usageFailure(usage);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseWholeNumber(value, usage) {
|
|
250
|
+
if (!/^\d+$/.test(String(value ?? ''))) throw usageFailure(usage);
|
|
251
|
+
return Number(value);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseRangeArgument(value, usage) {
|
|
255
|
+
const match = /^(\d+):(\d+)$/.exec(String(value ?? ''));
|
|
256
|
+
if (!match) throw usageFailure(usage);
|
|
257
|
+
return { start: Number(match[1]), length: Number(match[2]) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function documentInput(positionals, flags) {
|
|
261
|
+
const input = { document: positionals[0] };
|
|
262
|
+
if (flags.host !== undefined) input.host = flags.host;
|
|
263
|
+
return input;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function buildForDocument(context, flags) {
|
|
267
|
+
const hosts = flags.hosts === undefined ? undefined : String(flags.hosts).split(',');
|
|
268
|
+
return buildActions({
|
|
269
|
+
env: context.env,
|
|
270
|
+
cwd: context.cwd,
|
|
271
|
+
explicitEtag: true,
|
|
272
|
+
hosts,
|
|
273
|
+
hyperclayUrl: flags.hyperclayUrl ?? null,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function printResult(result, flags, context) {
|
|
278
|
+
if (flags.json) {
|
|
279
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const hasHtml = result && typeof result === 'object' && Object.hasOwn(result, 'html');
|
|
283
|
+
if (hasHtml && !Object.hasOwn(result, 'matches')) {
|
|
284
|
+
const html = String(result.html);
|
|
285
|
+
context.stdout.write(html);
|
|
286
|
+
if (!html.endsWith('\n')) context.stdout.write('\n');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function readInput(source, context) {
|
|
293
|
+
if (source === '-') return readAll(context.stdin);
|
|
294
|
+
return fs.readFile(source, 'utf8');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function readJsonArgument(value, context) {
|
|
298
|
+
if (value === '-') return readAll(context.stdin);
|
|
299
|
+
return String(value);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function listCommand(argv, context) {
|
|
303
|
+
const usage = COMMAND_USAGE.list;
|
|
304
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
305
|
+
const actions = await buildForDocument(context, flags);
|
|
306
|
+
const input = {};
|
|
307
|
+
if (positionals[0] !== undefined) input.where = positionals[0];
|
|
308
|
+
if (flags.host !== undefined) input.host = flags.host;
|
|
309
|
+
if (flags.cursor !== undefined) input.cursor = flags.cursor;
|
|
310
|
+
printResult(await actions.list_documents(input), flags, context);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function readCommand(argv, context) {
|
|
315
|
+
const usage = COMMAND_USAGE.read;
|
|
316
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
317
|
+
requirePositionals(positionals, 1, usage);
|
|
318
|
+
const actions = await buildForDocument(context, flags);
|
|
319
|
+
const input = documentInput(positionals, flags);
|
|
320
|
+
|
|
321
|
+
if (flags.outline) {
|
|
322
|
+
if (flags.depth !== undefined) input.depth = parseWholeNumber(flags.depth, usage);
|
|
323
|
+
if (flags.select !== undefined) input.select = flags.select;
|
|
324
|
+
printResult(await actions.outline_document(input), flags, context);
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (flags.find !== undefined || flags.regex !== undefined) {
|
|
329
|
+
if (flags.find !== undefined) input.text = flags.find;
|
|
330
|
+
if (flags.regex !== undefined) input.regex = flags.regex;
|
|
331
|
+
if (flags.limit !== undefined) input.limit = parseWholeNumber(flags.limit, usage);
|
|
332
|
+
printResult(await actions.find_in_document(input), flags, context);
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (flags.select !== undefined) input.select = flags.select;
|
|
337
|
+
if (flags.range !== undefined) input.range = parseRangeArgument(flags.range, usage);
|
|
338
|
+
if (flags.maxChars !== undefined) input.maxChars = parseWholeNumber(flags.maxChars, usage);
|
|
339
|
+
printResult(await actions.read_document(input), flags, context);
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function dataCommand(argv, context) {
|
|
344
|
+
const usage = COMMAND_USAGE.data;
|
|
345
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
346
|
+
requirePositionals(positionals, 1, usage);
|
|
347
|
+
const actions = await buildForDocument(context, flags);
|
|
348
|
+
const input = documentInput(positionals, flags);
|
|
349
|
+
|
|
350
|
+
if (flags.write === undefined) {
|
|
351
|
+
printResult(await actions.read_data(input), flags, context);
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const text = await readJsonArgument(flags.write, context);
|
|
356
|
+
let data;
|
|
357
|
+
try {
|
|
358
|
+
data = JSON.parse(text);
|
|
359
|
+
} catch {
|
|
360
|
+
throw new ClayError('invalid-json', 'That is not JSON.', { hint: 'Pass a JSON object, or - to read it from stdin.' });
|
|
361
|
+
}
|
|
362
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
363
|
+
throw new ClayError('invalid-json', 'The data must be a JSON object.', {
|
|
364
|
+
hint: 'Pass an object such as {"title":"Notes"}.',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
input.data = data;
|
|
368
|
+
if (flags.ifMatch !== undefined) input.etag = flags.ifMatch;
|
|
369
|
+
printResult(await actions.write_data(input), flags, context);
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function editCommand(argv, context) {
|
|
374
|
+
const usage = COMMAND_USAGE.edit;
|
|
375
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
376
|
+
requirePositionals(positionals, 1, usage);
|
|
377
|
+
const actions = await buildForDocument(context, flags);
|
|
378
|
+
const input = documentInput(positionals, flags);
|
|
379
|
+
|
|
380
|
+
if (flags.edits !== undefined) {
|
|
381
|
+
const text = await readInput(flags.edits, context);
|
|
382
|
+
try {
|
|
383
|
+
input.edits = JSON.parse(text);
|
|
384
|
+
} catch {
|
|
385
|
+
throw new ClayError('invalid-json', 'That edits file is not JSON.', {
|
|
386
|
+
hint: 'Pass an array of {"find","replace"} objects.',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
if (flags.find === undefined || flags.replace === undefined) throw usageFailure(usage);
|
|
391
|
+
const edit = { find: flags.find, replace: flags.replace };
|
|
392
|
+
if (flags.occurrence !== undefined) edit.occurrence = parseWholeNumber(flags.occurrence, usage);
|
|
393
|
+
input.edits = [edit];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (flags.ifMatch !== undefined) input.etag = flags.ifMatch;
|
|
397
|
+
printResult(await actions.edit_document(input), flags, context);
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function saveCommand(argv, context) {
|
|
402
|
+
const usage = COMMAND_USAGE.save;
|
|
403
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
404
|
+
requirePositionals(positionals, 1, usage);
|
|
405
|
+
if (flags.file === undefined) throw usageFailure(usage);
|
|
406
|
+
const actions = await buildForDocument(context, flags);
|
|
407
|
+
const input = documentInput(positionals, flags);
|
|
408
|
+
input.html = await readInput(flags.file, context);
|
|
409
|
+
if (flags.ifMatch !== undefined) input.etag = flags.ifMatch;
|
|
410
|
+
printResult(await actions.save_document(input), flags, context);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function versionsCommand(argv, context) {
|
|
415
|
+
const usage = COMMAND_USAGE.versions;
|
|
416
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
417
|
+
requirePositionals(positionals, 1, usage);
|
|
418
|
+
const actions = await buildForDocument(context, flags);
|
|
419
|
+
const input = documentInput(positionals, flags);
|
|
420
|
+
if (flags.before !== undefined) input.before = flags.before;
|
|
421
|
+
printResult(await actions.list_versions(input), flags, context);
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function versionCommand(argv, context) {
|
|
426
|
+
const usage = COMMAND_USAGE.version;
|
|
427
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
428
|
+
requirePositionals(positionals, 2, usage);
|
|
429
|
+
const actions = await buildForDocument(context, flags);
|
|
430
|
+
const input = documentInput(positionals, flags);
|
|
431
|
+
input.version = positionals[1];
|
|
432
|
+
if (flags.select !== undefined) input.select = flags.select;
|
|
433
|
+
if (flags.range !== undefined) input.range = parseRangeArgument(flags.range, usage);
|
|
434
|
+
if (flags.maxChars !== undefined) input.maxChars = parseWholeNumber(flags.maxChars, usage);
|
|
435
|
+
printResult(await actions.read_version(input), flags, context);
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function restoreCommand(argv, context) {
|
|
440
|
+
const usage = COMMAND_USAGE.restore;
|
|
441
|
+
const { flags, positionals } = parseCommandArgv(argv, usage);
|
|
442
|
+
requirePositionals(positionals, 2, usage);
|
|
443
|
+
const actions = await buildForDocument(context, flags);
|
|
444
|
+
const input = documentInput(positionals, flags);
|
|
445
|
+
input.version = positionals[1];
|
|
446
|
+
if (flags.ifMatch !== undefined) input.etag = flags.ifMatch;
|
|
447
|
+
printResult(await actions.restore_version(input), flags, context);
|
|
448
|
+
return 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function mcpCommand(argv, context) {
|
|
452
|
+
await runMcp({
|
|
453
|
+
env: context.env,
|
|
454
|
+
cwd: context.cwd,
|
|
455
|
+
argv,
|
|
456
|
+
});
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function dispatch(argv, context) {
|
|
461
|
+
const [command, ...rest] = argv;
|
|
462
|
+
if (command === '--help' || command === '-h') {
|
|
463
|
+
context.stdout.write(USAGE);
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
if (command === '--version' || command === '-V') {
|
|
467
|
+
context.stdout.write(`${VERSION}\n`);
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
if (!command) {
|
|
471
|
+
context.stderr.write(USAGE);
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
if (command === 'login') return login(rest, context);
|
|
475
|
+
if (command === 'logout') return logout(rest, context);
|
|
476
|
+
if (command === 'status') return status(rest, context);
|
|
477
|
+
if (command === 'mcp') return mcpCommand(rest, context);
|
|
478
|
+
if (command === 'list') return listCommand(rest, context);
|
|
479
|
+
if (command === 'read') return readCommand(rest, context);
|
|
480
|
+
if (command === 'data') return dataCommand(rest, context);
|
|
481
|
+
if (command === 'edit') return editCommand(rest, context);
|
|
482
|
+
if (command === 'save') return saveCommand(rest, context);
|
|
483
|
+
if (command === 'versions') return versionsCommand(rest, context);
|
|
484
|
+
if (command === 'version') return versionCommand(rest, context);
|
|
485
|
+
if (command === 'restore') return restoreCommand(rest, context);
|
|
486
|
+
context.stderr.write(USAGE);
|
|
487
|
+
return 1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function main(argv, { stdin, stdout, stderr, env, cwd }) {
|
|
491
|
+
const context = { stdin, stdout, stderr, env, cwd: cwd ?? process.cwd() };
|
|
492
|
+
const json = argv.includes('--json');
|
|
493
|
+
try {
|
|
494
|
+
if (argv.some((arg) => KEY_IN_ARGV.test(String(arg)))) {
|
|
495
|
+
throw new ClayError('invalid-argument', KEY_COMMAND_LINE);
|
|
496
|
+
}
|
|
497
|
+
return await dispatch(argv, context);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
const clay = asClayError(error);
|
|
500
|
+
if (clay.details?.usage) {
|
|
501
|
+
if (json) {
|
|
502
|
+
stdout.write(`${JSON.stringify({ error: { code: 'usage', message: clay.details.usage } }, null, 2)}\n`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
stderr.write(`${clay.details.usage}\n`);
|
|
506
|
+
if (clay.hint) stderr.write(`${clay.hint}\n`);
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
509
|
+
if (clay.code === 'conflict' && clay.etag) {
|
|
510
|
+
if (json) stdout.write(`${JSON.stringify(clay.toJSON(), null, 2)}\n`);
|
|
511
|
+
else stdout.write(`${clay.etag}\n`);
|
|
512
|
+
}
|
|
513
|
+
if (json) {
|
|
514
|
+
stderr.write(`${JSON.stringify(clay.toJSON(), null, 2)}\n`);
|
|
515
|
+
} else {
|
|
516
|
+
stderr.write(`${redact(clay.message)}\n`);
|
|
517
|
+
if (clay.hint) stderr.write(`${redact(clay.hint)}\n`);
|
|
518
|
+
}
|
|
519
|
+
return exitCode(clay.code);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { ClayError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BASE = 'https://hyperclay.com';
|
|
8
|
+
|
|
9
|
+
const KEY_PATTERN = /^hcak_[0-9a-f]{64}$/;
|
|
10
|
+
|
|
11
|
+
export function isAgentKey(key) {
|
|
12
|
+
return KEY_PATTERN.test(String(key ?? ''));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function homeDir(env = process.env) {
|
|
16
|
+
return env.HOME || env.USERPROFILE || os.homedir();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function configDir(env = process.env) {
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
return path.join(env.APPDATA || path.join(homeDir(env), 'AppData', 'Roaming'), 'clay-agent');
|
|
22
|
+
}
|
|
23
|
+
const root = env.XDG_CONFIG_HOME || path.join(homeDir(env), '.config');
|
|
24
|
+
return path.join(root, 'clay-agent');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function credentialsPath(env = process.env) {
|
|
28
|
+
return path.join(configDir(env), 'credentials.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function httpsOnly() {
|
|
32
|
+
return new ClayError('invalid-argument', 'The hyperclay URL must use https.', {
|
|
33
|
+
hint: 'Use https://hyperclay.com, or http only for localhost.',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isLoopbackHost(hostname) {
|
|
38
|
+
const host = String(hostname ?? '').toLowerCase();
|
|
39
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1' || host.endsWith('.localhost');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function baseUrl({ flag, env = process.env } = {}) {
|
|
43
|
+
const base = flag || env.CLAY_AGENT_URL || DEFAULT_BASE;
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(String(base));
|
|
47
|
+
} catch {
|
|
48
|
+
throw httpsOnly();
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw httpsOnly();
|
|
51
|
+
if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) throw httpsOnly();
|
|
52
|
+
return String(base).replace(/\/+$/, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readStore(file) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = await fs.readFile(file, 'utf8');
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code === 'ENOENT') return null;
|
|
61
|
+
throw new ClayError('key-missing', 'clay-agent could not read its credentials file.', {
|
|
62
|
+
hint: 'Run `clay-agent login` again.',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new ClayError('key-missing', 'clay-agent could not read its credentials file.', {
|
|
70
|
+
hint: 'Run `clay-agent login` again.',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
74
|
+
throw new ClayError('key-missing', 'clay-agent could not read its credentials file.', {
|
|
75
|
+
hint: 'Run `clay-agent login` again.',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeStore(file, data, dir) {
|
|
82
|
+
const temp = path.join(dir, `.credentials-${process.pid}-${Date.now()}.json`);
|
|
83
|
+
await fs.writeFile(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
84
|
+
await fs.chmod(temp, 0o600);
|
|
85
|
+
await fs.rename(temp, file);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkedKey(key) {
|
|
89
|
+
if (!isAgentKey(key)) {
|
|
90
|
+
throw new ClayError('invalid-key', 'CLAY_AGENT_KEY is not a valid agent key.', {
|
|
91
|
+
hint: 'Copy the key again from the dashboard; it is hcak_ followed by 64 lowercase letters and digits on one line.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function readKey(base, env = process.env) {
|
|
98
|
+
if (base === baseUrl({ env }) && env.CLAY_AGENT_KEY) {
|
|
99
|
+
const key = env.CLAY_AGENT_KEY.trim();
|
|
100
|
+
if (key) return checkedKey(key);
|
|
101
|
+
}
|
|
102
|
+
const file = credentialsPath(env);
|
|
103
|
+
const data = await readStore(file);
|
|
104
|
+
if (!data) return null;
|
|
105
|
+
const key = data.hosts?.[base]?.key;
|
|
106
|
+
if (typeof key !== 'string' || !key) return null;
|
|
107
|
+
return checkedKey(key.trim());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function saveKey(base, key, env = process.env) {
|
|
111
|
+
if (!isAgentKey(key)) {
|
|
112
|
+
throw new ClayError('invalid-key', 'That is not an agent key. Copy it from the dashboard: it starts with hcak_.');
|
|
113
|
+
}
|
|
114
|
+
const dir = configDir(env);
|
|
115
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
116
|
+
await fs.chmod(dir, 0o700);
|
|
117
|
+
const file = credentialsPath(env);
|
|
118
|
+
const existing = await readStore(file);
|
|
119
|
+
const data = {
|
|
120
|
+
v: 1,
|
|
121
|
+
hosts: { ...(existing?.hosts || {}) },
|
|
122
|
+
};
|
|
123
|
+
data.hosts[base] = { key };
|
|
124
|
+
await writeStore(file, data, dir);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function removeKey(base, env = process.env) {
|
|
128
|
+
const file = credentialsPath(env);
|
|
129
|
+
const existing = await readStore(file);
|
|
130
|
+
if (!existing?.hosts || !(base in existing.hosts)) return false;
|
|
131
|
+
delete existing.hosts[base];
|
|
132
|
+
const dir = configDir(env);
|
|
133
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
134
|
+
await fs.chmod(dir, 0o700);
|
|
135
|
+
await writeStore(file, { v: 1, hosts: existing.hosts }, dir);
|
|
136
|
+
return true;
|
|
137
|
+
}
|