@inixiative/archive 0.3.0 → 0.4.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/README.md CHANGED
@@ -45,20 +45,41 @@ Use `--source claude-code` for Claude histories. Collection matches the exact wo
45
45
 
46
46
  Deploy the included Dockerfile with a persistent volume mounted at `/data`, HTTPS, and a unique `ARCHIVE_SERVER_TOKEN` of at least 32 characters. Compose bind-mounts `./data` (or `ARCHIVE_DATA_DIR`) at `/data`, so the store survives rebuilds, `down -v` and volume prunes, and binds only to loopback on port 4411 (or `ARCHIVE_PORT`); put an HTTPS reverse proxy in front for remote use. Railway needs a `/data` volume. The Render blueprint provisions a dedicated disk and generated token.
47
47
 
48
- Place the destination's token in an environment variable, then run:
48
+ Place the destination's token in a private file (`chmod 600`, owned by you) or an environment variable, then run:
49
49
 
50
50
  ```sh
51
- bun run archive connect --url https://your-archive.example --project-id inixiative --token-env INIXIATIVE_ARCHIVE_TOKEN
51
+ bun run archive connect --url https://your-archive.example --project-id inixiative --token-file ~/.local/share/archive/credentials/inixiative.token
52
52
  bun run archive routes
53
53
  bun run archive sync
54
54
  bun run archive sync --watch
55
55
  bun run archive search --remote --query 'migration'
56
56
  ```
57
57
 
58
- `setup` is an alias for `connect`. Setup verifies access before saving configuration and does not upload. The URL locates the server; the token grants access. Configuration stores only the environment variable name. Secrets must be available in the collector/sync process environment. Redirects are refused; only HTTPS or loopback HTTP is allowed.
58
+ `setup` is an alias for `connect`. Setup verifies access before saving configuration and does not upload. The URL locates the server; the token grants access. Configuration stores only the token file path (`tokenFile`) or environment variable name (`--token-env`, `tokenEnv`), never the token. A token file is read on every request, so rotating it needs no restart; symlinks and files readable by group or others are refused. An environment variable must be set in the sync process environment. Redirects are refused; only HTTPS or loopback HTTP is allowed.
59
59
 
60
60
  `--home PATH`, `--store FILE` and `--config FILE` support custom paths. Keep the database with its source UUID when moving machines. Back up the entire database; a new store creates a new source identity.
61
61
 
62
+ ## Always-on local archiving
63
+
64
+ `archive agents` declares long-running agents in `~/.local/share/archive/agents.json` and installs them as launchd agents (macOS) or systemd user units (Linux):
65
+
66
+ ```sh
67
+ archive init
68
+ archive connect --url https://your-archive.example --project-id inixiative --token-file ~/.local/share/archive/credentials/inixiative.token
69
+ archive agents add-collector --name claude-code.inixiative --source claude-code --project-id inixiative --project-root ~/code/inixiative --worktrees
70
+ archive agents add-collector --name codex.inixiative --source codex --project-id inixiative --project-root ~/code/inixiative --worktrees
71
+ archive agents serve on
72
+ archive agents sync on
73
+ archive agents install
74
+ archive agents status
75
+ ```
76
+
77
+ `add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees` and `--atlas` behave as for `collect`. `remove-collector --name N`, `serve on|off [--port P]` and `sync on|off` edit the same file. `install` writes one unit per agent: `com.inixiative.archive.local` (serve), `com.inixiative.archive.sync` (`sync --watch`) and `com.inixiative.archive.collect.<name>`. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared; other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. One archive home per user account: units are named by agent, not by home. Sync reads destination token files itself, so no wrapper script is needed. Re-run `install` after upgrading the package if its install path changes.
78
+
79
+ Docker Compose is the alternative for the server: `docker compose up -d` bind-mounts `./data` (or `ARCHIVE_DATA_DIR`) at `/data` and binds loopback port 4411. It is a separate store, so connect it as a destination and let the `sync` agent publish to it; do not bind-mount the store the host agents are writing, since SQLite locking across the Docker VM boundary is unreliable. Collectors run on the host, where the session histories are.
80
+
81
+ `@inixiative/archive/agents` exports the pure pieces for integrations: `agentsConfigSchema`, `agentUnits`, `renderPlist`, `renderSystemdUnit` and `planAgents`, plus `installAgents`, `uninstallAgents` and `agentStatus` with an injectable command runner and supervisor directory.
82
+
62
83
  ## Connect through Kingdom
63
84
 
64
85
  Pair a runtime with Kingdom (Foundry: Settings → Kingdom) and expose its `kastle_runtime_` credential in an environment variable, then either let Kingdom store the archives:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/archive",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -14,7 +14,8 @@
14
14
  "./cli": "./src/cli.ts",
15
15
  "./collector": "./src/collector.ts",
16
16
  "./chatgpt": "./src/chatgpt.ts",
17
- "./tags": "./src/tags.ts"
17
+ "./tags": "./src/tags.ts",
18
+ "./agents": "./src/agents.ts"
18
19
  },
19
20
  "files": [
20
21
  "src"
@@ -35,7 +36,7 @@
35
36
  },
36
37
  "devDependencies": {
37
38
  "@biomejs/biome": "2.5.2",
38
- "@inixiative/config": "^0.5.36",
39
+ "@inixiative/config": "^0.5.37",
39
40
  "@types/bun": "1.4.2",
40
41
  "lefthook": "2.1.9",
41
42
  "typescript": "6.0.3"
package/src/agents.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readSync,
12
+ renameSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { z } from 'zod';
20
+
21
+ export const defaultHome = () => join(homedir(), '.local/share/archive');
22
+ export const AGENT_LABEL_PREFIX = 'com.inixiative.archive.';
23
+ /** History each provider writes by default. */
24
+ export const defaultHistoryDirectory = (source: 'claude-code' | 'codex') =>
25
+ join(homedir(), source === 'codex' ? '.codex/sessions' : '.claude/projects');
26
+
27
+ const absolute = z.string().max(4096).refine(isAbsolute, 'Path must be absolute');
28
+ export const collectorAgentSchema = z.strictObject({
29
+ name: z
30
+ .string()
31
+ .max(64)
32
+ .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
33
+ source: z.enum(['claude-code', 'codex']),
34
+ directory: absolute,
35
+ projectId: z.string().min(1).max(256),
36
+ projectRoots: z.array(absolute).min(1).max(100),
37
+ worktrees: z.boolean().optional(),
38
+ atlas: z.boolean().optional(),
39
+ });
40
+ export const agentsConfigSchema = z
41
+ .strictObject({
42
+ serve: z.strictObject({ port: z.number().int().min(1).max(65535).optional() }).optional(),
43
+ sync: z.boolean().optional(),
44
+ collectors: z.array(collectorAgentSchema).max(100),
45
+ })
46
+ .refine(
47
+ (c) => new Set(c.collectors.map((x) => x.name)).size === c.collectors.length,
48
+ 'Collector names must be unique',
49
+ );
50
+ export type AgentsConfig = z.infer<typeof agentsConfigSchema>;
51
+ export type CollectorAgent = z.infer<typeof collectorAgentSchema>;
52
+
53
+ export const readAgents = (file: string): AgentsConfig | undefined =>
54
+ existsSync(file) ? agentsConfigSchema.parse(JSON.parse(readFileSync(file, 'utf8'))) : undefined;
55
+ export function writeAgents(file: string, input: AgentsConfig) {
56
+ const config = agentsConfigSchema.parse(input);
57
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
58
+ const temp = `${file}.${randomUUID()}.tmp`;
59
+ writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
60
+ renameSync(temp, file);
61
+ chmodSync(file, 0o600);
62
+ return config;
63
+ }
64
+ export function upsertCollector(config: AgentsConfig | undefined, collector: CollectorAgent) {
65
+ const collectors = (config?.collectors ?? []).filter((c) => c.name !== collector.name);
66
+ return { ...config, collectors: [...collectors, collectorAgentSchema.parse(collector)] };
67
+ }
68
+
69
+ export interface AgentUnit {
70
+ label: string;
71
+ /** Full argv: runtime, CLI entrypoint, command. */
72
+ args: string[];
73
+ workingDirectory: string;
74
+ stdout: string;
75
+ stderr: string;
76
+ }
77
+ export interface AgentUnitOptions {
78
+ home: string;
79
+ execPath?: string;
80
+ /** Defaults to this package's own CLI, wherever it is installed. */
81
+ cliPath?: string;
82
+ }
83
+ export const packageCliPath = () => fileURLToPath(new URL('./cli.ts', import.meta.url));
84
+
85
+ export function collectorArgs(c: CollectorAgent) {
86
+ return [
87
+ 'collect',
88
+ '--directory',
89
+ c.directory,
90
+ '--source',
91
+ c.source,
92
+ ...c.projectRoots.flatMap((root) => ['--project-root', root]),
93
+ ...(c.worktrees ? ['--worktrees'] : []),
94
+ ...(c.atlas ? ['--atlas'] : []),
95
+ '--project-id',
96
+ c.projectId,
97
+ '--watch',
98
+ ];
99
+ }
100
+
101
+ /** One supervised unit per declared agent. */
102
+ export function agentUnits(config: AgentsConfig, options: AgentUnitOptions): AgentUnit[] {
103
+ const home = resolve(options.home);
104
+ const base = [options.execPath ?? process.execPath, options.cliPath ?? packageCliPath()];
105
+ const homeArgs = home === resolve(defaultHome()) ? [] : ['--home', home];
106
+ const unit = (suffix: string, command: string[]): AgentUnit => {
107
+ const label = AGENT_LABEL_PREFIX + suffix;
108
+ return {
109
+ label,
110
+ args: [...base, ...command, ...homeArgs],
111
+ workingDirectory: home,
112
+ stdout: join(home, 'logs', `${label}.out.log`),
113
+ stderr: join(home, 'logs', `${label}.err.log`),
114
+ };
115
+ };
116
+ return [
117
+ ...(config.serve
118
+ ? [
119
+ unit('local', [
120
+ 'serve',
121
+ ...(config.serve.port ? ['--port', String(config.serve.port)] : []),
122
+ ]),
123
+ ]
124
+ : []),
125
+ ...(config.sync ? [unit('sync', ['sync', '--watch'])] : []),
126
+ ...config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c))),
127
+ ];
128
+ }
129
+
130
+ const xml = (value: string) =>
131
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
132
+ export function renderPlist(unit: AgentUnit) {
133
+ const string = (value: string) => `<string>${xml(value)}</string>`;
134
+ return `<?xml version="1.0" encoding="UTF-8"?>
135
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
136
+ <plist version="1.0">
137
+ <dict>
138
+ <key>Label</key>
139
+ ${string(unit.label)}
140
+ <key>ProgramArguments</key>
141
+ <array>
142
+ ${unit.args.map((arg) => ` ${string(arg)}`).join('\n')}
143
+ </array>
144
+ <key>WorkingDirectory</key>
145
+ ${string(unit.workingDirectory)}
146
+ <key>RunAtLoad</key>
147
+ <true/>
148
+ <key>KeepAlive</key>
149
+ <true/>
150
+ <key>ThrottleInterval</key>
151
+ <integer>30</integer>
152
+ <key>StandardOutPath</key>
153
+ ${string(unit.stdout)}
154
+ <key>StandardErrorPath</key>
155
+ ${string(unit.stderr)}
156
+ </dict>
157
+ </plist>
158
+ `;
159
+ }
160
+
161
+ // systemd expands % specifiers and $ variables even inside quotes.
162
+ const systemdEscape = (value: string) => value.replace(/%/g, '%%').replace(/\$/g, '$$$$');
163
+ const systemdQuote = (arg: string) =>
164
+ `"${systemdEscape(arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"'))}"`;
165
+ export function renderSystemdUnit(unit: AgentUnit) {
166
+ return `[Unit]
167
+ Description=Archive ${unit.label.slice(AGENT_LABEL_PREFIX.length)}
168
+
169
+ [Service]
170
+ ExecStart=${unit.args.map(systemdQuote).join(' ')}
171
+ WorkingDirectory=${systemdEscape(unit.workingDirectory)}
172
+ Restart=always
173
+ RestartSec=30
174
+ StandardOutput=append:${systemdEscape(unit.stdout)}
175
+ StandardError=append:${systemdEscape(unit.stderr)}
176
+
177
+ [Install]
178
+ WantedBy=default.target
179
+ `;
180
+ }
181
+
182
+ export type AgentPlatform = 'darwin' | 'linux';
183
+ export const unitFileName = (label: string, platform: AgentPlatform) =>
184
+ label + (platform === 'darwin' ? '.plist' : '.service');
185
+ const ownedLabel = (label: string) =>
186
+ label === `${AGENT_LABEL_PREFIX}local` ||
187
+ label === `${AGENT_LABEL_PREFIX}sync` ||
188
+ label.startsWith(`${AGENT_LABEL_PREFIX}collect.`);
189
+ /** Labels of Archive units present in a supervisor directory. */
190
+ export function installedLabels(dir: string, platform: AgentPlatform) {
191
+ const extension = unitFileName('', platform);
192
+ return existsSync(dir)
193
+ ? readdirSync(dir)
194
+ .filter((file) => file.endsWith(extension))
195
+ .map((file) => file.slice(0, -extension.length))
196
+ .filter(ownedLabel)
197
+ .sort()
198
+ : [];
199
+ }
200
+
201
+ export interface AgentPlan {
202
+ write: { label: string; file: string; content: string }[];
203
+ unchanged: string[];
204
+ remove: string[];
205
+ }
206
+ /** Pure: which unit files to write, keep, or remove, given what is on disk. */
207
+ export function planAgents(
208
+ units: AgentUnit[],
209
+ installed: { label: string; content?: string }[],
210
+ platform: AgentPlatform,
211
+ dir: string,
212
+ ): AgentPlan {
213
+ const current = new Map(installed.map((i) => [i.label, i.content]));
214
+ const render = platform === 'darwin' ? renderPlist : renderSystemdUnit;
215
+ const plan: AgentPlan = { write: [], unchanged: [], remove: [] };
216
+ for (const unit of units) {
217
+ const content = render(unit);
218
+ if (current.get(unit.label) === content) plan.unchanged.push(unit.label);
219
+ else
220
+ plan.write.push({
221
+ label: unit.label,
222
+ file: join(dir, unitFileName(unit.label, platform)),
223
+ content,
224
+ });
225
+ }
226
+ const wanted = new Set(units.map((u) => u.label));
227
+ plan.remove = installed.map((i) => i.label).filter((l) => ownedLabel(l) && !wanted.has(l));
228
+ return plan;
229
+ }
230
+
231
+ export type AgentRunner = (argv: string[]) => { code: number; stdout: string; stderr: string };
232
+ const spawnRunner: AgentRunner = (argv) => {
233
+ const run = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe' });
234
+ return { code: run.exitCode ?? 1, stdout: run.stdout.toString(), stderr: run.stderr.toString() };
235
+ };
236
+ export interface AgentHostOptions {
237
+ home?: string;
238
+ platform?: AgentPlatform;
239
+ /** Supervisor directory; defaults to ~/Library/LaunchAgents or ~/.config/systemd/user. */
240
+ dir?: string;
241
+ run?: AgentRunner;
242
+ uid?: number;
243
+ sleep?: (ms: number) => void;
244
+ execPath?: string;
245
+ cliPath?: string;
246
+ }
247
+ function host(options: AgentHostOptions) {
248
+ const platform =
249
+ options.platform ??
250
+ (process.platform === 'darwin' || process.platform === 'linux'
251
+ ? process.platform
252
+ : (() => {
253
+ throw new Error('Agents support macOS launchd and Linux systemd only');
254
+ })());
255
+ const home = resolve(options.home ?? defaultHome());
256
+ const dir =
257
+ options.dir ??
258
+ join(homedir(), platform === 'darwin' ? 'Library/LaunchAgents' : '.config/systemd/user');
259
+ const run = options.run ?? spawnRunner;
260
+ const domain = `gui/${options.uid ?? process.getuid?.()}`;
261
+ const sleep = options.sleep ?? Bun.sleepSync;
262
+ const file = (label: string) => join(dir, unitFileName(label, platform));
263
+ const systemctl = (...args: string[]) => run(['systemctl', '--user', ...args]);
264
+ const unload = (label: string) => {
265
+ if (platform === 'darwin') run(['launchctl', 'bootout', `${domain}/${label}`]);
266
+ else systemctl('disable', '--now', `${label}.service`);
267
+ };
268
+ const load = (label: string) => {
269
+ if (platform === 'linux') {
270
+ const enabled = systemctl('enable', `${label}.service`);
271
+ const started = systemctl('restart', `${label}.service`);
272
+ if (enabled.code || started.code) throw new Error(`systemctl failed for ${label}`);
273
+ return;
274
+ }
275
+ run(['launchctl', 'bootout', `${domain}/${label}`]);
276
+ // bootout returns before launchd finishes tearing down; bootstrap then fails with I/O error 5.
277
+ for (let attempt = 1; ; attempt++) {
278
+ const result = run(['launchctl', 'bootstrap', domain, file(label)]);
279
+ if (!result.code) return;
280
+ if (attempt >= 5 || (result.code !== 5 && !/Input\/output error/.test(result.stderr)))
281
+ throw new Error(`launchctl bootstrap failed for ${label}: ${result.stderr.trim()}`);
282
+ sleep(1000);
283
+ }
284
+ };
285
+ const loaded = (label: string) => {
286
+ if (platform === 'darwin') {
287
+ const result = run(['launchctl', 'print', `${domain}/${label}`]);
288
+ if (result.code) return { loaded: false };
289
+ const pid = /\bpid = (\d+)/.exec(result.stdout)?.[1];
290
+ return { loaded: true, pid: pid ? Number(pid) : undefined };
291
+ }
292
+ const result = systemctl('show', `${label}.service`, '--property=ActiveState,MainPID');
293
+ const state = /ActiveState=(\w+)/.exec(result.stdout)?.[1];
294
+ const pid = Number(/MainPID=(\d+)/.exec(result.stdout)?.[1] ?? 0);
295
+ return { loaded: state === 'active' || state === 'activating', pid: pid || undefined };
296
+ };
297
+ const remove = (labels: string[]) => {
298
+ for (const label of labels) {
299
+ unload(label);
300
+ rmSync(file(label), { force: true });
301
+ }
302
+ if (platform === 'linux' && labels.length) systemctl('daemon-reload');
303
+ };
304
+ return { platform, home, dir, file, load, loaded, remove, systemctl };
305
+ }
306
+
307
+ /** Writes and (re)loads declared agents; removes Archive units no longer declared. */
308
+ export function installAgents(config: AgentsConfig, options: AgentHostOptions = {}) {
309
+ const h = host(options);
310
+ const units = agentUnits(config, {
311
+ home: h.home,
312
+ execPath: options.execPath,
313
+ cliPath: options.cliPath,
314
+ });
315
+ mkdirSync(join(h.home, 'logs'), { recursive: true, mode: 0o700 });
316
+ mkdirSync(h.dir, { recursive: true });
317
+ const installed = installedLabels(h.dir, h.platform).map((label) => ({
318
+ label,
319
+ content: readFileSync(h.file(label), 'utf8'),
320
+ }));
321
+ const plan = planAgents(units, installed, h.platform, h.dir);
322
+ h.remove(plan.remove);
323
+ for (const { file, content } of plan.write) {
324
+ const temp = `${file}.${randomUUID()}.tmp`;
325
+ writeFileSync(temp, content, { mode: 0o644, flag: 'wx' });
326
+ renameSync(temp, file);
327
+ }
328
+ if (h.platform === 'linux' && plan.write.length) h.systemctl('daemon-reload');
329
+ // Unchanged units that are not running are loaded again.
330
+ const reload = [
331
+ ...plan.write.map((w) => w.label),
332
+ ...plan.unchanged.filter((label) => !h.loaded(label).loaded),
333
+ ];
334
+ for (const label of reload) h.load(label);
335
+ return {
336
+ platform: h.platform,
337
+ dir: h.dir,
338
+ loaded: reload,
339
+ unchanged: plan.unchanged.filter((l) => !reload.includes(l)),
340
+ removed: plan.remove,
341
+ };
342
+ }
343
+
344
+ export function uninstallAgents(options: AgentHostOptions = {}) {
345
+ const h = host(options);
346
+ const labels = installedLabels(h.dir, h.platform);
347
+ h.remove(labels);
348
+ return { platform: h.platform, dir: h.dir, removed: labels };
349
+ }
350
+
351
+ /** Last log entry: CLI output is pretty JSON, so the last top-level value, compacted; else the last line. */
352
+ function lastEntry(file: string) {
353
+ if (!existsSync(file)) return undefined;
354
+ const fd = openSync(file, 'r');
355
+ try {
356
+ const size = fstatSync(fd).size;
357
+ const buffer = Buffer.alloc(Math.min(size, 65_536));
358
+ readSync(fd, buffer, 0, buffer.length, size - buffer.length);
359
+ const lines = buffer.toString('utf8').trimEnd().split('\n');
360
+ const start = lines.findLastIndex((line) => line === '{' || line === '[');
361
+ if (start >= 0)
362
+ try {
363
+ return JSON.stringify(JSON.parse(lines.slice(start).join('\n'))).slice(0, 500);
364
+ } catch {}
365
+ return lines.at(-1) || undefined;
366
+ } finally {
367
+ closeSync(fd);
368
+ }
369
+ }
370
+
371
+ export function agentStatus(config: AgentsConfig | undefined, options: AgentHostOptions = {}) {
372
+ const h = host(options);
373
+ const units = config
374
+ ? agentUnits(config, { home: h.home, execPath: options.execPath, cliPath: options.cliPath })
375
+ : [];
376
+ const declared = new Set(units.map((u) => u.label));
377
+ const labels = [...new Set([...declared, ...installedLabels(h.dir, h.platform)])].sort();
378
+ return labels.map((label) => ({
379
+ label,
380
+ declared: declared.has(label),
381
+ installed: existsSync(h.file(label)),
382
+ ...h.loaded(label),
383
+ lastOut: lastEntry(join(h.home, 'logs', `${label}.out.log`)),
384
+ lastErr: lastEntry(join(h.home, 'logs', `${label}.err.log`)),
385
+ }));
386
+ }
package/src/cli.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
4
  import { join, resolve } from 'node:path';
6
5
  import { parseArgs } from 'node:util';
6
+ import {
7
+ agentStatus,
8
+ defaultHistoryDirectory,
9
+ defaultHome,
10
+ installAgents,
11
+ readAgents,
12
+ uninstallAgents,
13
+ upsertCollector,
14
+ writeAgents,
15
+ } from './agents';
7
16
  import { importChatGPTThread } from './chatgpt';
8
17
  import {
9
18
  archiveRequest,
@@ -26,7 +35,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
26
35
  args,
27
36
  allowPositionals: true,
28
37
  options: {
29
- home: { type: 'string', default: join(homedir(), '.local/share/archive') },
38
+ home: { type: 'string', default: defaultHome() },
30
39
  store: { type: 'string' },
31
40
  config: { type: 'string' },
32
41
  url: { type: 'string' },
@@ -36,7 +45,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
36
45
  'owner-model': { type: 'string' },
37
46
  'organization-id': { type: 'string' },
38
47
  'space-id': { type: 'string' },
39
- 'token-env': { type: 'string', default: 'ARCHIVE_TOKEN' },
48
+ 'token-env': { type: 'string' },
40
49
  'token-file': { type: 'string' },
41
50
  file: { type: 'string' },
42
51
  directory: { type: 'string' },
@@ -50,7 +59,8 @@ export async function runCli(args = Bun.argv.slice(2)) {
50
59
  atlas: { type: 'boolean', default: false },
51
60
  tag: { type: 'string', multiple: true },
52
61
  limit: { type: 'string', default: '100' },
53
- port: { type: 'string', default: process.env.PORT ?? '4411' },
62
+ port: { type: 'string' },
63
+ name: { type: 'string' },
54
64
  hostname: { type: 'string', default: '127.0.0.1' },
55
65
  watch: { type: 'boolean', default: false },
56
66
  remote: { type: 'boolean', default: false },
@@ -65,12 +75,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
65
75
  const command = positionals[0];
66
76
  if (v.help || !command) {
67
77
  console.log(
68
- 'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search\n' +
69
- 'connect --url HTTPS_URL --project-id ID --token-env ENV [--kind kingdom [--connection-id ID] [--owner-model M --organization-id UUID --space-id UUID]]\n' +
78
+ 'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search | agents\n' +
79
+ 'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) [--kind kingdom [--connection-id ID] [--owner-model M --organization-id UUID --space-id UUID]]\n' +
70
80
  'import --file PATH --source codex|claude-code --project-id ID [--tag TAG]\n' +
71
81
  'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
72
82
  'sync [--watch] | routes | search --query TEXT [--remote]\n' +
73
83
  'serve --home PATH [--port 4411 --hostname 127.0.0.1]\n' +
84
+ 'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
85
+ 'agents remove-collector --name N | serve on|off [--port P] | sync on|off | install | uninstall | status\n' +
74
86
  'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
75
87
  );
76
88
  return;
@@ -94,7 +106,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
94
106
  const instance = startArchiveServer({
95
107
  store: storePath,
96
108
  token,
97
- port: Number(v.port),
109
+ port: Number(v.port ?? process.env.PORT ?? 4411),
98
110
  hostname: v.hostname,
99
111
  });
100
112
  output({ listening: instance.server.url.href, store: storePath });
@@ -107,10 +119,12 @@ export async function runCli(args = Bun.argv.slice(2)) {
107
119
  return;
108
120
  }
109
121
  if (command === 'connect' || command === 'setup') {
122
+ const tokenFile = v['token-file'] && resolve(v['token-file']);
110
123
  const destination = archiveDestinationSchema.parse({
111
124
  projectId: v['project-id'],
112
125
  url: v.url,
113
- tokenEnv: v['token-env'],
126
+ tokenEnv: v['token-env'] ?? (tokenFile ? undefined : 'ARCHIVE_TOKEN'),
127
+ tokenFile,
114
128
  kind: v.kind,
115
129
  ...(v.kind === 'kingdom'
116
130
  ? {
@@ -135,6 +149,58 @@ export async function runCli(args = Bun.argv.slice(2)) {
135
149
  output(connectDestination(config, destination));
136
150
  return;
137
151
  }
152
+ if (command === 'agents') {
153
+ const file = join(home, 'agents.json');
154
+ const current = readAgents(file);
155
+ const [action, state] = positionals.slice(1);
156
+ const toggle = () => {
157
+ if (state !== 'on' && state !== 'off') throw new Error(`agents ${action} requires on|off`);
158
+ return state === 'on';
159
+ };
160
+ const base = { collectors: [], ...current };
161
+ if (action === 'add-collector') {
162
+ if (!v.name || !v['project-id'] || !v['project-root'] || !v.source)
163
+ throw new Error('add-collector requires --name, --source, --project-id and --project-root');
164
+ const source = v.source as 'codex' | 'claude-code';
165
+ output(
166
+ writeAgents(
167
+ file,
168
+ upsertCollector(current, {
169
+ name: v.name,
170
+ source,
171
+ directory: resolve(v.directory ?? defaultHistoryDirectory(source)),
172
+ projectId: v['project-id'],
173
+ projectRoots: v['project-root'].map((root) => resolve(root)),
174
+ ...(v.worktrees ? { worktrees: true } : {}),
175
+ ...(v.atlas ? { atlas: true } : {}),
176
+ }),
177
+ ),
178
+ );
179
+ } else if (action === 'remove-collector') {
180
+ if (!v.name) throw new Error('remove-collector requires --name');
181
+ output(
182
+ writeAgents(file, {
183
+ ...base,
184
+ collectors: base.collectors.filter((c) => c.name !== v.name),
185
+ }),
186
+ );
187
+ } else if (action === 'serve') {
188
+ const { serve: _, ...rest } = base;
189
+ output(
190
+ writeAgents(
191
+ file,
192
+ toggle() ? { ...rest, serve: v.port ? { port: Number(v.port) } : {} } : rest,
193
+ ),
194
+ );
195
+ } else if (action === 'sync') output(writeAgents(file, { ...base, sync: toggle() }));
196
+ else if (action === 'install') {
197
+ if (!current) throw new Error('No agents.json; declare agents first');
198
+ output(installAgents(current, { home }));
199
+ } else if (action === 'uninstall') output(uninstallAgents({ home }));
200
+ else if (action === 'status') output(agentStatus(current, { home }));
201
+ else throw new Error('Unknown agents command; use --help');
202
+ return;
203
+ }
138
204
  if (command === 'preview') {
139
205
  if (
140
206
  Boolean(v.file) === Boolean(v.directory) ||
package/src/client.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  type ArchiveDestination,
3
3
  archiveDestinationSchema,
4
4
  destinationIdentity,
5
+ destinationToken,
5
6
  destinationUrl,
6
7
  } from './config';
7
8
  import type { LocalArchiveStore } from './local';
@@ -26,7 +27,7 @@ export async function archiveRequest(
26
27
  transport: typeof fetch = fetch,
27
28
  ) {
28
29
  const url = destinationUrl(destination.url);
29
- const token = process.env[destination.tokenEnv];
30
+ const token = destinationToken(destination);
30
31
  if (!token || (destination.kind === 'kingdom' && !token.startsWith('kastle_runtime_')))
31
32
  throw new Error('Archive runtime credential unavailable');
32
33
  const path =
package/src/config.ts CHANGED
@@ -1,12 +1,27 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
- import { dirname } from 'node:path';
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ constants,
6
+ existsSync,
7
+ fstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ writeFileSync,
13
+ } from 'node:fs';
14
+ import { dirname, isAbsolute } from 'node:path';
4
15
  import { z } from 'zod';
5
16
 
6
17
  const common = {
7
18
  projectId: z.string().min(1).max(256),
8
19
  url: z.url(),
9
- tokenEnv: z.string().regex(/^[A-Z][A-Z0-9_]+$/),
20
+ tokenEnv: z
21
+ .string()
22
+ .regex(/^[A-Z][A-Z0-9_]+$/)
23
+ .optional(),
24
+ tokenFile: z.string().max(4096).refine(isAbsolute, 'tokenFile must be absolute').optional(),
10
25
  };
11
26
  /** Kingdom derives the owner from the runtime credential; these narrow it to a space or organization it manages. */
12
27
  export const kingdomOwnerFields = {
@@ -14,26 +29,46 @@ export const kingdomOwnerFields = {
14
29
  organizationId: z.uuid().optional(),
15
30
  spaceId: z.uuid().optional(),
16
31
  };
17
- export const archiveDestinationSchema = z.union([
18
- z.strictObject({
19
- ...common,
20
- kind: z.literal('archive'),
21
- keepIds: z.array(z.string()).max(0).optional(),
22
- }),
23
- z.strictObject({
24
- ...common,
25
- kind: z.literal('kingdom'),
26
- /** A hosted Archive that Kingdom forwards to; omitted, Kingdom stores the archive itself. */
27
- connectionId: z
28
- .string()
29
- .regex(/^[a-z0-9-]+$/)
30
- .max(120)
31
- .optional(),
32
- ...kingdomOwnerFields,
33
- }),
34
- ]);
32
+ export const archiveDestinationSchema = z
33
+ .union([
34
+ z.strictObject({
35
+ ...common,
36
+ kind: z.literal('archive'),
37
+ keepIds: z.array(z.string()).max(0).optional(),
38
+ }),
39
+ z.strictObject({
40
+ ...common,
41
+ kind: z.literal('kingdom'),
42
+ /** A hosted Archive that Kingdom forwards to; omitted, Kingdom stores the archive itself. */
43
+ connectionId: z
44
+ .string()
45
+ .regex(/^[a-z0-9-]+$/)
46
+ .max(120)
47
+ .optional(),
48
+ ...kingdomOwnerFields,
49
+ }),
50
+ ])
51
+ .refine(
52
+ (d) => Boolean(d.tokenEnv) !== Boolean(d.tokenFile),
53
+ 'Set exactly one of tokenEnv or tokenFile',
54
+ );
35
55
  export type ArchiveDestination = z.infer<typeof archiveDestinationSchema>;
36
56
 
57
+ /** Private regular file owned by this user; a symlink or group/world access is refused. */
58
+ export function readTokenFile(path: string) {
59
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
60
+ try {
61
+ const stat = fstatSync(fd);
62
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077)
63
+ throw new Error('Token file must be a regular file owned by you with mode 0600');
64
+ return readFileSync(fd, 'utf8').trim();
65
+ } finally {
66
+ closeSync(fd);
67
+ }
68
+ }
69
+ export const destinationToken = (destination: ArchiveDestination) =>
70
+ destination.tokenFile ? readTokenFile(destination.tokenFile) : process.env[destination.tokenEnv!];
71
+
37
72
  export function destinationUrl(input: string) {
38
73
  const url = new URL(input);
39
74
  if (
@@ -93,6 +128,8 @@ export function connectDestination(file: string, input: unknown) {
93
128
  kind: destination.kind,
94
129
  projectId: destination.projectId,
95
130
  url: destination.url,
96
- tokenEnv: destination.tokenEnv,
131
+ ...(destination.tokenFile
132
+ ? { tokenFile: destination.tokenFile }
133
+ : { tokenEnv: destination.tokenEnv }),
97
134
  };
98
135
  }