resumecontext 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 (44) hide show
  1. package/README.md +27 -0
  2. package/dist/agentConfig.js +202 -0
  3. package/dist/agentConfigWithDaemon.js +42 -0
  4. package/dist/apiClient.js +54 -0
  5. package/dist/browser.js +20 -0
  6. package/dist/cloudApi.js +15 -0
  7. package/dist/commands/accept.js +20 -0
  8. package/dist/commands/agents.js +35 -0
  9. package/dist/commands/auth.js +55 -0
  10. package/dist/commands/daemon.js +99 -0
  11. package/dist/commands/init.js +59 -0
  12. package/dist/commands/logout.js +20 -0
  13. package/dist/commands/mcp.js +54 -0
  14. package/dist/commands/members.js +20 -0
  15. package/dist/commands/projects.js +55 -0
  16. package/dist/commands/revoke.js +15 -0
  17. package/dist/commands/share.js +18 -0
  18. package/dist/commands/sync.js +59 -0
  19. package/dist/commands/uninstall.js +61 -0
  20. package/dist/constants.js +61 -0
  21. package/dist/daemon.js +409 -0
  22. package/dist/daemonService.js +326 -0
  23. package/dist/deps.js +1 -0
  24. package/dist/dev.js +32 -0
  25. package/dist/device.js +40 -0
  26. package/dist/httpCloudApi.js +61 -0
  27. package/dist/index.js +160 -0
  28. package/dist/localCapture.js +18 -0
  29. package/dist/localHistory/claudeCode.js +82 -0
  30. package/dist/localHistory/codex.js +106 -0
  31. package/dist/localHistory/cursor.js +492 -0
  32. package/dist/localHistory/index.js +96 -0
  33. package/dist/localHistory/opencode.js +148 -0
  34. package/dist/localHistory/registry.js +66 -0
  35. package/dist/localHistory/shared.js +174 -0
  36. package/dist/paths.js +85 -0
  37. package/dist/projectRoot.js +77 -0
  38. package/dist/session.js +36 -0
  39. package/dist/syncCore.js +108 -0
  40. package/dist/syncState.js +51 -0
  41. package/dist/ui.js +289 -0
  42. package/dist/utils.js +41 -0
  43. package/dist/version.js +43 -0
  44. package/package.json +64 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Registers auto-sync as a real OS-scheduled mechanism -- launchd
3
+ * StartInterval on macOS, a systemd --user timer on Linux -- so it survives
4
+ * a reboot and keeps firing on schedule, rather than being just a detached
5
+ * subprocess that a `kill -9`, a log-out, or a restart silently ends for
6
+ * good.
7
+ *
8
+ * Each platform's mechanism triggers a fresh, short-lived process per tick
9
+ * (daemon.ts's runDaemonTick) instead of supervising one long-running
10
+ * process -- see daemon.ts's module doc comment for why that's the design.
11
+ * launchd does this natively via StartInterval (RunAtLoad fires the first
12
+ * tick immediately, StartInterval fires every one after). systemd has no
13
+ * exact equivalent on the service unit itself, so Linux uses a `.timer`
14
+ * unit (the schedule) paired with a `Type=oneshot` `.service` unit (the
15
+ * work) -- the timer is what gets enabled/started; the service is what the
16
+ * timer activates each time. Both platforms end up with the identical
17
+ * shape from daemon.ts's point of view: something re-invokes this CLI with
18
+ * the hidden `__daemon-run` subcommand every DAEMON_INTERVAL_MS.
19
+ *
20
+ * Every OS-touching function here is split into a pure "build the plan"
21
+ * half (the config file contents and the commands to run, computed with no
22
+ * I/O) and a thin "apply it" half that actually writes files and runs
23
+ * commands -- with fs/exec injectable on the apply half specifically so
24
+ * tests can verify the exact plan produced without ever writing to a real
25
+ * user's ~/Library/LaunchAgents or ~/.config/systemd, or running a real
26
+ * launchctl/systemctl command. Nothing in this file's test suite is
27
+ * permitted to touch the real OS service manager -- see daemonService.test.ts.
28
+ */
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { execFileSync } from "node:child_process";
33
+ import crypto from "node:crypto";
34
+ import { DAEMON_INTERVAL_MS } from "./constants.js";
35
+ import { daemonLogFile } from "./paths.js";
36
+ import { cliVersion } from "./version.js";
37
+ export const DAEMON_SUBCOMMAND = "__daemon-run";
38
+ const DAEMON_INTERVAL_SECONDS = DAEMON_INTERVAL_MS / 1000;
39
+ /** Re-invokes this exact process as one daemon tick: same interpreter,
40
+ * same loader flags (tsx's --require/--import hooks in dev, none once this
41
+ * is the compiled dist/index.js), same entry script, plus the hidden
42
+ * daemon subcommand -- so both the launchd/systemd plans below agree on
43
+ * exactly how to relaunch themselves. */
44
+ export function daemonProgramArguments() {
45
+ return [process.execPath, ...process.execArgv, process.argv[1], DAEMON_SUBCOMMAND];
46
+ }
47
+ /**
48
+ * The environment overrides the service has to carry.
49
+ *
50
+ * Neither launchd nor systemd inherits the shell that installed the service,
51
+ * so without this a daemon registered by a CLI pointed at a local or staging
52
+ * backend would wake up pointed at production, and push that project's
53
+ * sessions there. These two are the only variables that change which backend
54
+ * is talked to and which credentials are used, so they are the only two worth
55
+ * baking in.
56
+ *
57
+ * Empty for an ordinary install, which is the point: the service file for
58
+ * someone who has never set either of these is byte-for-byte what it always
59
+ * was. And because installPersistentService compares file contents, switching
60
+ * between backends rewrites the file and forces a reinstall rather than
61
+ * leaving a daemon quietly pointed at the old one.
62
+ */
63
+ export function daemonEnvironment() {
64
+ const env = {};
65
+ for (const key of ["RESUMECONTEXT_API_URL", "RESUMECONTEXT_HOME"]) {
66
+ const value = process.env[key];
67
+ if (value)
68
+ env[key] = value;
69
+ }
70
+ return env;
71
+ }
72
+ /**
73
+ * The launchd label and systemd unit names for THIS configuration.
74
+ *
75
+ * An ordinary install gets the plain names. A CLI running with either override
76
+ * (the dev CLI, pointed at a local backend with its own state directory) gets
77
+ * names suffixed with a hash of those overrides. Sharing one name meant signing
78
+ * in with the dev CLI rewrote the real CLI's service file -- pointing the real
79
+ * daemon at localhost and a different state directory, so real projects
80
+ * stopped syncing until the real CLI happened to reinstall it, which in turn
81
+ * silently unregistered the dev one.
82
+ */
83
+ export function serviceNames() {
84
+ const env = daemonEnvironment();
85
+ const suffix = Object.keys(env).length === 0
86
+ ? ""
87
+ : `-${crypto.createHash("sha256").update(JSON.stringify(Object.entries(env).sort())).digest("hex").slice(0, 8)}`;
88
+ return {
89
+ launchdLabel: `com.resumecontext.daemon${suffix.replace("-", ".")}`,
90
+ systemdService: `resumecontext-daemon${suffix}.service`,
91
+ systemdTimer: `resumecontext-daemon${suffix}.timer`,
92
+ };
93
+ }
94
+ export function detectPlatform() {
95
+ if (process.platform === "darwin")
96
+ return "launchd";
97
+ if (process.platform === "linux")
98
+ return "systemd";
99
+ return "unsupported";
100
+ }
101
+ function xmlEscape(s) {
102
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
103
+ }
104
+ /** `homeDir` defaults to the real home directory but is a parameter (not
105
+ * baked in) specifically so tests can point every path this produces at a
106
+ * scratch directory, as a second line of defense on top of fs injection --
107
+ * even a test that slipped and used the real fs would land somewhere
108
+ * harmless.
109
+ *
110
+ * `version` defaults to cliVersion() but is likewise a parameter so tests
111
+ * can pin it -- it's embedded below purely as a comment so that a version
112
+ * bump changes the file's contents even when daemonProgramArguments()
113
+ * doesn't (an in-place reinstall at the same path, e.g. `npm install -g`
114
+ * over an existing global install). installPersistentService's staleness
115
+ * check is a plain string comparison of every file's contents, so this is
116
+ * enough to make every version bump force a reinstall -- see that
117
+ * function's doc comment. */
118
+ export function buildLaunchdPlan(homeDir = os.homedir(), version = cliVersion()) {
119
+ const args = daemonProgramArguments();
120
+ const logFile = daemonLogFile();
121
+ const { launchdLabel: LAUNCHD_LABEL } = serviceNames();
122
+ const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
123
+ const env = daemonEnvironment();
124
+ const envBlock = Object.keys(env).length === 0
125
+ ? ""
126
+ : ` <key>EnvironmentVariables</key>
127
+ <dict>
128
+ ${Object.entries(env)
129
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
130
+ .join("\n")}
131
+ </dict>
132
+ `;
133
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
134
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
135
+ <!-- resumecontext-cli-version: ${xmlEscape(version)} -->
136
+ <plist version="1.0">
137
+ <dict>
138
+ <key>Label</key>
139
+ <string>${LAUNCHD_LABEL}</string>
140
+ <key>ProgramArguments</key>
141
+ <array>
142
+ ${args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
143
+ </array>
144
+ ${envBlock} <key>RunAtLoad</key>
145
+ <true/>
146
+ <key>StartInterval</key>
147
+ <integer>${DAEMON_INTERVAL_SECONDS}</integer>
148
+ <key>StandardOutPath</key>
149
+ <string>${xmlEscape(logFile)}</string>
150
+ <key>StandardErrorPath</key>
151
+ <string>${xmlEscape(logFile)}</string>
152
+ </dict>
153
+ </plist>
154
+ `;
155
+ return {
156
+ files: [{ path: plistPath, contents }],
157
+ // `launchctl list <label>` exits non-zero when the label isn't loaded.
158
+ // Deliberately NOT KeepAlive: that restarts the process the instant it
159
+ // exits, which for a one-shot tick means immediately, fighting
160
+ // StartInterval instead of waiting for it. launchd's own singleton
161
+ // behavior per label already means it won't start a second instance
162
+ // of this label while StartInterval's previous invocation is still
163
+ // running (daemon.ts's file lock is the belt-and-suspenders backstop
164
+ // for that, portable to systemd where the same guarantee is less
165
+ // absolute).
166
+ statusCommand: ["launchctl", "list", LAUNCHD_LABEL],
167
+ installCommands: [["launchctl", "load", "-w", plistPath]],
168
+ uninstallCommands: [["launchctl", "unload", "-w", plistPath]],
169
+ };
170
+ }
171
+ /** Same `version` parameter and reasoning as buildLaunchdPlan above. */
172
+ export function buildSystemdPlan(homeDir = os.homedir(), version = cliVersion()) {
173
+ const args = daemonProgramArguments();
174
+ const logFile = daemonLogFile();
175
+ const { systemdService: SYSTEMD_SERVICE_UNIT, systemdTimer: SYSTEMD_TIMER_UNIT } = serviceNames();
176
+ const systemdUserDir = path.join(homeDir, ".config", "systemd", "user");
177
+ const servicePath = path.join(systemdUserDir, SYSTEMD_SERVICE_UNIT);
178
+ const timerPath = path.join(systemdUserDir, SYSTEMD_TIMER_UNIT);
179
+ const quote = (s) => (/\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
180
+ const envLines = Object.entries(daemonEnvironment())
181
+ .map(([k, v]) => `Environment=${k}=${quote(v)}\n`)
182
+ .join("");
183
+ const serviceContents = `# resumecontext-cli-version: ${version}
184
+ [Unit]
185
+ Description=resumecontext auto-sync (one tick)
186
+
187
+ [Service]
188
+ Type=oneshot
189
+ ${envLines}ExecStart=${args.map(quote).join(" ")}
190
+ StandardOutput=append:${logFile}
191
+ StandardError=append:${logFile}
192
+ `;
193
+ // OnBootSec=0 fires the first tick immediately once the timer starts
194
+ // (mirrors launchd's RunAtLoad); OnUnitActiveSec counts from when the
195
+ // triggered service last finished, not from a fixed clock, so a slow
196
+ // tick doesn't cause the next one to fire early or overlap.
197
+ const timerContents = `[Unit]
198
+ Description=resumecontext auto-sync timer
199
+
200
+ [Timer]
201
+ OnBootSec=0
202
+ OnUnitActiveSec=${DAEMON_INTERVAL_SECONDS}s
203
+ Unit=${SYSTEMD_SERVICE_UNIT}
204
+
205
+ [Install]
206
+ WantedBy=timers.target
207
+ `;
208
+ return {
209
+ files: [
210
+ { path: servicePath, contents: serviceContents },
211
+ { path: timerPath, contents: timerContents },
212
+ ],
213
+ // Checks the TIMER, not the oneshot service -- see ServicePlan's doc.
214
+ statusCommand: ["systemctl", "--user", "is-active", "--quiet", SYSTEMD_TIMER_UNIT],
215
+ // Enabling/starting the TIMER is what schedules the service; the
216
+ // service unit itself is never enabled or started directly.
217
+ installCommands: [
218
+ ["systemctl", "--user", "daemon-reload"],
219
+ ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT],
220
+ ],
221
+ uninstallCommands: [["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT]],
222
+ };
223
+ }
224
+ export function buildPlan(platform, homeDir, version) {
225
+ if (platform === "launchd")
226
+ return buildLaunchdPlan(homeDir, version);
227
+ if (platform === "systemd")
228
+ return buildSystemdPlan(homeDir, version);
229
+ return null;
230
+ }
231
+ function defaultExec(command, args) {
232
+ execFileSync(command, args, { stdio: "ignore" });
233
+ }
234
+ /** True if `plan`'s schedule is already registered/active. */
235
+ export function isServiceActive(execImpl = defaultExec, homeDir) {
236
+ const plan = buildPlan(detectPlatform(), homeDir);
237
+ if (!plan)
238
+ return false;
239
+ try {
240
+ execImpl(plan.statusCommand[0], plan.statusCommand.slice(1));
241
+ return true;
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }
247
+ function readFileIfPresent(fsImpl, filePath) {
248
+ try {
249
+ return fsImpl.readFileSync(filePath, "utf8");
250
+ }
251
+ catch {
252
+ return null; // never installed, or the file was removed out from under us
253
+ }
254
+ }
255
+ /** True only if EVERY file in the plan is present on disk with exactly
256
+ * the contents we'd write today -- systemd's plan is two files (service +
257
+ * timer), and either one being stale is enough to warrant reinstalling
258
+ * both, since they're only ever meaningful as a pair. */
259
+ function planUpToDate(fsImpl, plan) {
260
+ return plan.files.every((f) => readFileIfPresent(fsImpl, f.path) === f.contents);
261
+ }
262
+ export function installPersistentService(fsImpl = fs, execImpl = defaultExec, homeDir) {
263
+ const platform = detectPlatform();
264
+ const plan = buildPlan(platform, homeDir);
265
+ if (!plan)
266
+ return { platform, installed: false };
267
+ // "Already active" is NOT enough to skip: what's registered could be
268
+ // running the WRONG program, or an OUTDATED one. Two distinct cases,
269
+ // both covered by the same plain string comparison:
270
+ // - the launch path changed: daemonProgramArguments() bakes in
271
+ // process.execPath/argv[1], so a new versioned install path, a moved
272
+ // checkout, or a first run under `tsx` followed by a real install
273
+ // leave the schedule pointing somewhere stale.
274
+ // - an in-place upgrade at the SAME path (e.g. `npm install -g` over an
275
+ // existing global install): the launch path doesn't change, but the
276
+ // code on disk did. buildLaunchdPlan/buildSystemdPlan embed
277
+ // cliVersion() as a comment specifically so this case also shows up
278
+ // as a content difference.
279
+ // Either way: compare every on-disk file against what we'd write now,
280
+ // and treat any difference as "must reinstall".
281
+ if (planUpToDate(fsImpl, plan) && isServiceActive(execImpl, homeDir))
282
+ return { platform, installed: false };
283
+ for (const file of plan.files) {
284
+ fsImpl.mkdirSync(path.dirname(file.path), { recursive: true });
285
+ fsImpl.writeFileSync(file.path, file.contents);
286
+ }
287
+ // Unload the stale definition before loading the new one -- `launchctl
288
+ // load` on an already-loaded label is an error, and a stale systemd
289
+ // timer would otherwise keep firing the OLD service definition until
290
+ // restarted. Failure here is expected and ignored: it just means
291
+ // nothing was loaded to begin with.
292
+ for (const [cmd, ...args] of plan.uninstallCommands) {
293
+ try {
294
+ execImpl(cmd, args);
295
+ }
296
+ catch {
297
+ // not currently loaded -- nothing to unload
298
+ }
299
+ }
300
+ for (const [cmd, ...args] of plan.installCommands)
301
+ execImpl(cmd, args);
302
+ return { platform, installed: true };
303
+ }
304
+ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsImpl = fs) {
305
+ const platform = detectPlatform();
306
+ const plan = buildPlan(platform, homeDir);
307
+ if (!plan)
308
+ return { platform, uninstalled: false };
309
+ const wasActive = isServiceActive(execImpl, homeDir);
310
+ for (const [cmd, ...args] of plan.uninstallCommands) {
311
+ try {
312
+ execImpl(cmd, args);
313
+ }
314
+ catch {
315
+ // not currently loaded/enabled -- nothing to unload
316
+ }
317
+ }
318
+ let removedFiles = false;
319
+ for (const file of plan.files) {
320
+ if (fsImpl.existsSync(file.path)) {
321
+ fsImpl.unlinkSync(file.path);
322
+ removedFiles = true;
323
+ }
324
+ }
325
+ return { platform, uninstalled: wasActive || removedFiles };
326
+ }
package/dist/deps.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/dev.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `npm run dev` -- the CLI pointed at a backend running on this machine.
3
+ *
4
+ * Two overrides, both of which the CLI already honours; this file only
5
+ * supplies dev defaults for them so nobody has to remember the incantation:
6
+ *
7
+ * RESUMECONTEXT_API_URL the backend. `npm run dev` in ../backend listens
8
+ * on 3001, and that backend's own API_URL and
9
+ * WEBSITE_URL point at localhost, so the sign-in URL
10
+ * and the MCP endpoint it hands back are local too.
11
+ * Nothing else needs configuring.
12
+ *
13
+ * RESUMECONTEXT_HOME local state. Pointed at a separate directory so a
14
+ * dev sign-in does not overwrite the credentials and
15
+ * device id of the real CLI, whose tokens are issued
16
+ * by a different backend and would be rejected by it.
17
+ *
18
+ * Both are only defaults: setting either in the environment wins, so this
19
+ * still works for pointing at a staging backend or sharing the real home.
20
+ *
21
+ * The daemon is handled too. daemonProgramArguments() bakes `process.argv[1]`
22
+ * into the service file, which for this entrypoint is this file -- so a daemon
23
+ * registered by the dev CLI re-enters here on every tick and picks the same
24
+ * two defaults back up. It also gets them written into the service file
25
+ * explicitly (see daemonService.ts), because an exported variable is not
26
+ * inherited by launchd or systemd.
27
+ */
28
+ import path from "node:path";
29
+ import os from "node:os";
30
+ process.env.RESUMECONTEXT_API_URL ||= "http://localhost:3001";
31
+ process.env.RESUMECONTEXT_HOME ||= path.join(os.homedir(), ".resumecontext-dev");
32
+ await import("./index.js");
package/dist/device.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * This machine's identity.
3
+ *
4
+ * A bare UUID, generated once and kept in the CLI's home directory rather than
5
+ * in any project: it identifies the MACHINE, and the same machine syncs many
6
+ * projects. There is deliberately no human-readable name -- a hostname would
7
+ * be shared with everyone on a project without anyone choosing to share it,
8
+ * and nothing in the product needs to display one. The id only ever has to
9
+ * answer "same machine or not".
10
+ *
11
+ * It is sent once, when starting a device sign-in, and the backend binds it
12
+ * into the issued token. Every later push and MCP call is then attributable to
13
+ * this machine without the CLI sending anything further, and without a client
14
+ * being able to claim a machine that is not its own.
15
+ */
16
+ import crypto from "node:crypto";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { deviceFile } from "./paths.js";
20
+ /** The server rejects anything that is not a UUID, so a stored value that no
21
+ * longer looks like one is treated as corrupt and replaced -- otherwise a
22
+ * machine whose device.json got mangled could never sign in again. */
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ export function deviceId() {
25
+ const file = deviceFile();
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
28
+ if (typeof parsed?.id === "string" && UUID_RE.test(parsed.id))
29
+ return parsed.id;
30
+ }
31
+ catch {
32
+ // Missing or corrupt -- fall through and mint a new one. A machine that
33
+ // loses its id looks like a new machine, which is a harmless degradation:
34
+ // older sessions read as "another machine of yours" rather than "here".
35
+ }
36
+ const id = crypto.randomUUID();
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ fs.writeFileSync(file, JSON.stringify({ id }, null, 2));
39
+ return id;
40
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The real CloudApi implementation -- every method is one axios call
3
+ * through the shared apiClient (see apiClient.ts), which is also where all
4
+ * 401 handling lives. Every command already goes through this same
5
+ * interface, so nothing above this file needs to know or care that it's
6
+ * now backed by real HTTP instead of an in-process fake.
7
+ */
8
+ import { apiClient } from "./apiClient.js";
9
+ import { deviceId } from "./device.js";
10
+ import { API_VERSION } from "./constants.js";
11
+ function bearer(token) {
12
+ return { headers: { Authorization: `Bearer ${token}` } };
13
+ }
14
+ export class HttpCloudApi {
15
+ async startDeviceLogin() {
16
+ // The machine id goes in at the START of the flow, so whichever way the
17
+ // browser finishes it -- a fresh Google sign-in, or an existing web
18
+ // session -- the token that comes back is bound to this machine.
19
+ const { data } = await apiClient.post("/auth/device/start", {
20
+ deviceId: deviceId(),
21
+ });
22
+ return data;
23
+ }
24
+ async pollDeviceLogin(deviceCode) {
25
+ const { data } = await apiClient.get(`/auth/device/poll/${deviceCode}`);
26
+ return data;
27
+ }
28
+ async createProject(token, label) {
29
+ const { data } = await apiClient.post(`/${API_VERSION}/projects`, { label }, bearer(token));
30
+ return data;
31
+ }
32
+ async findProject(token, projectId) {
33
+ const { data } = await apiClient.get(`/${API_VERSION}/projects/${projectId}`, bearer(token));
34
+ return data;
35
+ }
36
+ async pushTurns(token, projectId, turns) {
37
+ const { data } = await apiClient.post(`/${API_VERSION}/projects/${projectId}/turns`, { turns }, bearer(token));
38
+ return data;
39
+ }
40
+ async shareProject(token, projectId, email) {
41
+ await apiClient.post(`/${API_VERSION}/projects/${projectId}/members`, { email }, bearer(token));
42
+ }
43
+ async acceptInvite(token, projectId) {
44
+ const { data } = await apiClient.post(`/${API_VERSION}/projects/${projectId}/accept`, {}, bearer(token));
45
+ return data;
46
+ }
47
+ async revokeAccess(token, projectId, email) {
48
+ await apiClient.delete(`/${API_VERSION}/projects/${projectId}/members/${encodeURIComponent(email)}`, bearer(token));
49
+ }
50
+ async listMembers(token, projectId) {
51
+ const { data } = await apiClient.get(`/${API_VERSION}/projects/${projectId}/members`, bearer(token));
52
+ return data.members;
53
+ }
54
+ async listProjects(token) {
55
+ const { data } = await apiClient.get(`/${API_VERSION}/projects`, bearer(token));
56
+ return data.projects;
57
+ }
58
+ async deleteProject(token, projectId) {
59
+ await apiClient.delete(`/${API_VERSION}/projects/${projectId}`, bearer(token));
60
+ }
61
+ }
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Commander wiring: routes CLI args to the pure command functions in
4
+ * commands/*.ts, injecting the real Deps (HttpCloudApi -- real HTTP via the
5
+ * shared axios client against api.resumecontext.com, and
6
+ * RealLocalCapture reading each agent's own local history via
7
+ * localHistory/). This is the ONLY file that
8
+ * constructs real dependencies or touches process.exit for error handling
9
+ * -- every command function itself just throws a plain Error with a
10
+ * user-facing message and returns.
11
+ */
12
+ import { Command } from "commander";
13
+ import chalk from "chalk";
14
+ import { HttpCloudApi } from "./httpCloudApi.js";
15
+ import { RealLocalCapture } from "./localCapture.js";
16
+ import { ensureAgentConfigWithDaemon } from "./agentConfigWithDaemon.js";
17
+ import { runDaemonTick } from "./daemon.js";
18
+ import { DAEMON_SUBCOMMAND } from "./daemonService.js";
19
+ import { cliVersion } from "./version.js";
20
+ import * as ui from "./ui.js";
21
+ import { runAuth } from "./commands/auth.js";
22
+ import { runInit } from "./commands/init.js";
23
+ import { runSync } from "./commands/sync.js";
24
+ import { runShare } from "./commands/share.js";
25
+ import { runAccept } from "./commands/accept.js";
26
+ import { runRevoke } from "./commands/revoke.js";
27
+ import { runMembers } from "./commands/members.js";
28
+ import { runMcp } from "./commands/mcp.js";
29
+ import { runAgents } from "./commands/agents.js";
30
+ import { runLogout } from "./commands/logout.js";
31
+ import { runDaemonStatus, runDaemonStart, runDaemonStop } from "./commands/daemon.js";
32
+ import { runProjectsList, runProjectsDelete } from "./commands/projects.js";
33
+ import { runUninstall } from "./commands/uninstall.js";
34
+ const cloudApi = new HttpCloudApi();
35
+ const localCapture = new RealLocalCapture();
36
+ function deps() {
37
+ return { cloudApi, cwd: process.cwd(), ensureAgentConfig: ensureAgentConfigWithDaemon };
38
+ }
39
+ function syncDeps() {
40
+ return { ...deps(), localCapture };
41
+ }
42
+ function withErrorHandling(action) {
43
+ return async (...args) => {
44
+ try {
45
+ await action(...args);
46
+ }
47
+ catch (err) {
48
+ ui.printError(err);
49
+ if (program.opts().debug && err instanceof Error && err.stack) {
50
+ console.error(chalk.dim(err.stack));
51
+ }
52
+ process.exitCode = 1;
53
+ }
54
+ };
55
+ }
56
+ const program = new Command();
57
+ program
58
+ .name("resumecontext")
59
+ .description("Shared context for coding agents -- resumecontext.com")
60
+ .version(cliVersion())
61
+ .option("--debug", "show full stack traces on unexpected errors");
62
+ program
63
+ .command("auth")
64
+ .description("log in to resumecontext.com (opens your browser)")
65
+ .action(withErrorHandling(() => runAuth(deps())));
66
+ program
67
+ .command("logout")
68
+ .description("clear this machine's stored session")
69
+ .action(withErrorHandling(() => runLogout()));
70
+ program
71
+ .command("init")
72
+ .description("connect this directory to a resumecontext project")
73
+ .action(withErrorHandling(() => runInit(deps())));
74
+ program
75
+ .command("sync")
76
+ .description("push local coding-agent history for this project")
77
+ .action(withErrorHandling(() => runSync(syncDeps())));
78
+ program
79
+ .command("share")
80
+ .description("invite someone to this project (owner only)")
81
+ .argument("<email>", "email address to invite")
82
+ .action(withErrorHandling((email) => runShare(deps(), email)));
83
+ program
84
+ .command("accept")
85
+ .description("accept a pending invite for this project")
86
+ .action(withErrorHandling(() => runAccept(deps())));
87
+ program
88
+ .command("revoke")
89
+ .description("remove someone's access to this project (owner only)")
90
+ .argument("<email>", "email address to remove")
91
+ .action(withErrorHandling((email) => runRevoke(deps(), email)));
92
+ program
93
+ .command("members")
94
+ .description("list everyone with access to this project (owner only)")
95
+ .action(withErrorHandling(() => runMembers(deps())));
96
+ program
97
+ .command("mcp")
98
+ .description("show the MCP URL and token for configuring your coding agent")
99
+ .action(withErrorHandling(() => runMcp(deps())));
100
+ program
101
+ .command("agents")
102
+ .description("choose which coding agents to sync from, and where their data lives")
103
+ .action(withErrorHandling(() => runAgents(deps())));
104
+ const daemonCmd = program.command("daemon").description("manage the background auto-sync service");
105
+ daemonCmd
106
+ .command("status")
107
+ .description("show whether auto-sync is running")
108
+ .action(withErrorHandling(() => runDaemonStatus()));
109
+ daemonCmd
110
+ .command("start")
111
+ .description("(re)enable auto-sync -- rarely needed, every project command does this on its own")
112
+ .action(withErrorHandling(() => runDaemonStart()));
113
+ daemonCmd
114
+ .command("stop")
115
+ .description("stop auto-sync (restarts next time you run a project command like `sync`)")
116
+ .action(withErrorHandling(() => runDaemonStop()));
117
+ const projectsCmd = program.command("projects").description("manage your resumecontext projects");
118
+ projectsCmd
119
+ .command("list")
120
+ .description("list every project you own or have access to")
121
+ .action(withErrorHandling(() => runProjectsList(deps())));
122
+ projectsCmd
123
+ .command("delete")
124
+ .description("permanently delete a project (owner only)")
125
+ .argument("<projectId>", "project id, from `resumecontext projects list`")
126
+ .action(withErrorHandling((projectId) => runProjectsDelete(deps(), projectId)));
127
+ program
128
+ .command("uninstall")
129
+ .description("stop auto-sync and delete all local resumecontext state (asks to confirm)")
130
+ .action(withErrorHandling(() => runUninstall()));
131
+ // Hidden: not something a user runs directly. This is the entry point the
132
+ // OS scheduler invokes for EVERY tick -- the launchd plist's
133
+ // ProgramArguments (StartInterval) and the systemd timer's triggered
134
+ // oneshot service (ExecStart) both re-invoke this same binary with this
135
+ // subcommand every DAEMON_INTERVAL_MS (see daemonProgramArguments in
136
+ // daemonService.ts). Each invocation is a fresh process that runs one tick
137
+ // and exits -- see daemon.ts's module doc comment for why that's the
138
+ // design (self-healing from a hung/crashed tick) rather than one
139
+ // persistent process looping forever.
140
+ //
141
+ // Explicitly calls process.exit() rather than letting the process exit
142
+ // naturally once the event loop empties: an open keep-alive HTTP
143
+ // connection to the cloud API (or anything else holding a handle open)
144
+ // would otherwise leave the process alive indefinitely, and since it
145
+ // still holds daemon.ts's lock file while alive, that would silently wedge
146
+ // every future tick forever -- the exact failure mode this redesign
147
+ // exists to eliminate. Bypasses withErrorHandling for the same reason
148
+ // runDaemonLoop used to: that wrapper prints to the terminal and sets
149
+ // process.exitCode for an interactive command, neither of which means
150
+ // anything for a background process with no terminal.
151
+ program
152
+ .command(DAEMON_SUBCOMMAND, { hidden: true })
153
+ .action(() => runDaemonTick(cloudApi, localCapture).then(() => process.exit(0), (err) => {
154
+ console.error(err);
155
+ process.exit(1);
156
+ }));
157
+ async function main() {
158
+ await program.parseAsync(process.argv);
159
+ }
160
+ main();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Behind the LocalCapture interface so command tests never need real local
3
+ * ~/.claude / ~/.cursor / etc. data on the machine running them -- they
4
+ * inject a fake that returns canned turns instead. The real implementation
5
+ * (RealLocalCapture) just calls localHistory/'s in-process parser --
6
+ * nothing here shells out to another package or reads another package's
7
+ * output files; this CLI owns its own local-history parsing end to end.
8
+ *
9
+ * Takes the agent config as an argument rather than reading it: the config
10
+ * is keyed by projectId (see agentConfig.ts), which this layer has no way
11
+ * to know from a project root alone. The caller already resolved it.
12
+ */
13
+ import { collectLocalTurns } from "./localHistory/index.js";
14
+ export class RealLocalCapture {
15
+ async scan(projectRoot, config) {
16
+ return collectLocalTurns(projectRoot, config);
17
+ }
18
+ }