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
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # resumecontext
2
+
3
+ Your coding agents forget. Your teammates' agents never knew.
4
+
5
+ `resumecontext` records the sessions your coding agents already write to disk,
6
+ syncs them to one archive per project, and hands that archive back to every
7
+ agent through MCP. Your agent can then answer from what you and your team
8
+ actually did: which approach was abandoned and why, what a teammate changed
9
+ last week, where the work stopped when a usage limit hit.
10
+
11
+ ```bash
12
+ npm install -g resumecontext
13
+
14
+ resumecontext auth # sign in, binding this machine
15
+ cd ~/code/my-service
16
+ resumecontext init # this directory becomes the project
17
+ resumecontext mcp # the config block to paste into your agent
18
+ ```
19
+
20
+ From then on it syncs on its own, roughly every twenty seconds. There is no
21
+ sync command to remember and nothing to change about how you prompt.
22
+
23
+ - **Docs:** https://resumecontext.com/docs
24
+ - **Quickstart:** https://resumecontext.com/docs/quickstart
25
+ - **Which agents are supported:** https://resumecontext.com/docs/cli/agents
26
+
27
+ Requires Node 22 or newer.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Per-project configuration of which coding agents this CLI scans for local
3
+ * history, and where their data lives on disk. Never assumed to be the
4
+ * OS-default locations -- the user picks, once per project, and can change
5
+ * it later with `resumecontext agents`. Persisted (keyed by projectId, see
6
+ * paths.ts) so a first-time user is prompted exactly once per project,
7
+ * whichever project-scoped command they happen to run first -- see
8
+ * ensureAgentConfig, which every command in commands/*.ts calls after
9
+ * resolving its project, except auth/logout which have no project.
10
+ *
11
+ * Scoped per project rather than per machine because different projects
12
+ * legitimately draw on different agents: one repo's history might live
13
+ * entirely in Claude Code while another was worked on in Cursor, and
14
+ * scanning stores that were never used for a project is wasted work.
15
+ *
16
+ * Each agent gets a LIST of directories, not one: the same agent can keep
17
+ * history in more than one place (a non-standard install, another machine's
18
+ * data copied over, a data dir that moved whose older history still matters).
19
+ *
20
+ * Everything agent-specific -- labels, default locations, what a valid data
21
+ * directory looks like -- comes from localHistory/registry.ts, so this file
22
+ * works unchanged as more agents are supported.
23
+ */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import chalk from "chalk";
27
+ import { agentConfigFile } from "./paths.js";
28
+ import { resolveUserPath, unique } from "./utils.js";
29
+ import * as ui from "./ui.js";
30
+ import { AGENT_ADAPTERS, ALL_AGENT_NAMES } from "./localHistory/registry.js";
31
+ export { ALL_AGENT_NAMES };
32
+ /** Parses a config file's contents, ignoring anything that doesn't match
33
+ * the shape written by writeAgentConfig -- unknown agent names and entries
34
+ * without a `dirs` array are dropped rather than trusted, so a hand-edited
35
+ * or truncated file degrades to "configure this project again" instead of
36
+ * crashing a command or silently scanning nothing. */
37
+ function parseConfig(raw) {
38
+ if (!raw || typeof raw !== "object")
39
+ return null;
40
+ const rawAgents = raw.agents;
41
+ if (!rawAgents || typeof rawAgents !== "object")
42
+ return null;
43
+ const agents = {};
44
+ for (const [name, value] of Object.entries(rawAgents)) {
45
+ if (!ALL_AGENT_NAMES.includes(name))
46
+ continue;
47
+ const dirs = value?.dirs;
48
+ if (!Array.isArray(dirs))
49
+ continue;
50
+ agents[name] = { dirs: unique(dirs.filter((d) => typeof d === "string")) };
51
+ }
52
+ return { agents };
53
+ }
54
+ export function readAgentConfig(projectId) {
55
+ try {
56
+ return parseConfig(JSON.parse(fs.readFileSync(agentConfigFile(projectId), "utf-8")));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ export function writeAgentConfig(projectId, config) {
63
+ const file = agentConfigFile(projectId);
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ fs.writeFileSync(file, JSON.stringify(config, null, 2));
66
+ }
67
+ const ADD_ROW = "\0add";
68
+ const NEXT_ROW = "\0next";
69
+ /** Asks for one new directory, accepting it only if it actually looks like
70
+ * this agent's data directory -- an unrecognised path is refused outright
71
+ * rather than offered as an override, since silently syncing from a
72
+ * directory we can't parse would just fail later, further from the cause.
73
+ *
74
+ * A rejection re-prompts with what they typed still editable, so fixing a
75
+ * typo is immediate; submitting an empty line backs out to the directory
76
+ * list. Both paths out are stated in the prompt itself, so there are no
77
+ * yes/no questions in this flow. Returns null when they back out. */
78
+ async function promptForNewDir(adapter, alreadyListed) {
79
+ let seed = "";
80
+ while (true) {
81
+ const answer = await ui.promptText(`Path to a ${adapter.label} data directory ${chalk.dim("(leave empty to go back)")}`, { initialValue: seed, placeholder: adapter.defaultDir });
82
+ if (!answer.trim())
83
+ return null;
84
+ const dir = resolveUserPath(answer);
85
+ if (alreadyListed.includes(dir)) {
86
+ ui.log.warn(`${dir} is already in the list.`);
87
+ seed = answer;
88
+ continue;
89
+ }
90
+ if (adapter.looksLikeDataDir(dir))
91
+ return dir;
92
+ ui.log.warn(`${dir} doesn't look like a ${adapter.label} data directory, so it can't be added.`);
93
+ ui.log.info(`Expected to find: ${adapter.expectedShape}`);
94
+ seed = answer; // keep it editable rather than making them retype
95
+ }
96
+ }
97
+ /** The directory screen for one agent: the paths currently configured for
98
+ * it, each showing whether it validates, plus buttons to add another and
99
+ * to move on. Rebuilt and re-prompted after every action so the list on
100
+ * screen always reflects the current state. */
101
+ async function promptForAgentDirs(adapter, existing) {
102
+ // Seed with the usual location so the common case is just pressing Next,
103
+ // while still being a listed, removable entry rather than an assumption.
104
+ let dirs = unique(existing?.length ? existing : [adapter.defaultDir]);
105
+ let cursorAt;
106
+ while (true) {
107
+ const rows = dirs.map((dir) => ({
108
+ value: dir,
109
+ label: dir,
110
+ tone: "item",
111
+ hint: adapter.looksLikeDataDir(dir) ? chalk.green("✔ recognised") : chalk.yellow("⚠ not recognised"),
112
+ help: "enter to remove this path · ↑/↓ to move",
113
+ }));
114
+ if (dirs.length === 0) {
115
+ rows.push({ value: "\0empty", label: "(no directories yet)", tone: "warn", help: "add one below" });
116
+ }
117
+ rows.push({
118
+ value: ADD_ROW,
119
+ label: "+ Add another directory",
120
+ tone: "action",
121
+ separated: true,
122
+ help: "enter to add a directory",
123
+ });
124
+ rows.push({
125
+ value: NEXT_ROW,
126
+ label: dirs.length > 0 ? "→ Next" : "→ Next (nothing will be synced for this agent)",
127
+ tone: dirs.length > 0 ? "action" : "warn",
128
+ help: "enter to continue",
129
+ });
130
+ const choice = await ui.menu(`Where does ${adapter.label} store its data?`, rows, { cursorAt });
131
+ if (choice === NEXT_ROW)
132
+ return dirs;
133
+ if (choice === ADD_ROW) {
134
+ const added = await promptForNewDir(adapter, dirs);
135
+ if (added)
136
+ dirs.push(added);
137
+ cursorAt = ADD_ROW; // stay put so another add is one keypress away
138
+ continue;
139
+ }
140
+ if (choice === "\0empty") {
141
+ cursorAt = ADD_ROW;
142
+ continue;
143
+ }
144
+ // A path row: remove it. Reversible via Add, so no confirmation step.
145
+ dirs = dirs.filter((d) => d !== choice);
146
+ cursorAt = dirs.length > 0 ? undefined : ADD_ROW;
147
+ }
148
+ }
149
+ /** Interactively asks which agents to sync from and where their data lives,
150
+ * then persists the answer. EVERY supported agent is offered, but only the
151
+ * ones actually detected on this machine are pre-selected -- someone with
152
+ * just Claude Code and Codex shouldn't have to deselect the rest, while
153
+ * someone keeping Cursor's data somewhere non-standard can still select it
154
+ * and type the path. */
155
+ export async function promptForAgentConfig(projectId, existing) {
156
+ const detected = ALL_AGENT_NAMES.filter((name) => AGENT_ADAPTERS[name].looksLikeDataDir(AGENT_ADAPTERS[name].defaultDir));
157
+ // Reconfiguring keeps the current selection; a first run falls back to
158
+ // whatever was detected locally.
159
+ const alreadyConfigured = existing ? Object.keys(existing.agents) : [];
160
+ const preselected = alreadyConfigured.length > 0 ? alreadyConfigured : detected;
161
+ const selected = (await ui.multiselect("Which coding agents should resumecontext sync from?", ALL_AGENT_NAMES.map((name) => ({
162
+ value: name,
163
+ label: AGENT_ADAPTERS[name].label,
164
+ hint: detected.includes(name) ? "found on this machine" : "not found in the usual place",
165
+ })), preselected));
166
+ const config = { agents: {} };
167
+ if (selected.length === 0) {
168
+ ui.log.warn("No agents selected -- `resumecontext sync` won't find anything to push.");
169
+ writeAgentConfig(projectId, config);
170
+ return config;
171
+ }
172
+ // Restate the choice in plain words before asking for paths -- the picker
173
+ // is transient, and this is the last chance to notice a mis-toggle before
174
+ // walking through directories for the wrong set of agents.
175
+ const skipped = ALL_AGENT_NAMES.filter((name) => !selected.includes(name));
176
+ ui.log.success(`Will sync: ${selected.map((n) => AGENT_ADAPTERS[n].label).join(", ")}`);
177
+ if (skipped.length > 0) {
178
+ ui.log.info(`Skipping: ${skipped.map((n) => AGENT_ADAPTERS[n].label).join(", ")}`);
179
+ }
180
+ for (const name of selected) {
181
+ const dirs = await promptForAgentDirs(AGENT_ADAPTERS[name], existing?.agents[name]?.dirs);
182
+ // An agent left with no directories is the same as not selecting it, so
183
+ // record it that way rather than storing a stub that scans nothing.
184
+ if (dirs.length === 0) {
185
+ ui.log.warn(`No directories set for ${AGENT_ADAPTERS[name].label} -- skipping it.`);
186
+ continue;
187
+ }
188
+ config.agents[name] = { dirs };
189
+ }
190
+ writeAgentConfig(projectId, config);
191
+ return config;
192
+ }
193
+ /** Called at the top of every project-scoped command: returns the existing
194
+ * config if the user has already set one up, or runs the interactive prompt
195
+ * once (and persists the result) if not. */
196
+ export async function ensureAgentConfig(projectId) {
197
+ const existing = readAgentConfig(projectId);
198
+ if (existing)
199
+ return existing;
200
+ ui.log.warn("First time syncing this project -- let's set up which coding agents to sync from.");
201
+ return promptForAgentConfig(projectId);
202
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Composes agentConfig.ts's prompt-or-return with daemon registration --
3
+ * the single place "a project's agent config is settled (whether newly
4
+ * prompted or just already on disk)" gets wired to
5
+ * "the daemon should now cover it." Kept out of both agentConfig.ts and
6
+ * daemon.ts on purpose: agentConfig.ts stays with zero knowledge of
7
+ * daemons, daemon.ts stays with zero knowledge of the interactive setup
8
+ * flow, and daemon.ts already imports readAgentConfig from agentConfig.ts
9
+ * -- having agentConfig.ts import back from daemon.ts would make that a
10
+ * cycle.
11
+ *
12
+ * Scoped to exactly that: the project-scoped commands routed through
13
+ * Deps.ensureAgentConfig. `resumecontext agents` deliberately does NOT go
14
+ * through here -- it only edits config that a running daemon re-reads every
15
+ * tick anyway, so routing it through here would buy nothing and would
16
+ * silently restart a daemon the user explicitly stopped.
17
+ */
18
+ import { ensureAgentConfig } from "./agentConfig.js";
19
+ import { registerProject, ensureDaemonRunning } from "./daemon.js";
20
+ import * as ui from "./ui.js";
21
+ function warnIfDaemonUnsupported(ensureDaemonRunningFn) {
22
+ const { platform } = ensureDaemonRunningFn();
23
+ if (platform === "unsupported") {
24
+ ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
25
+ }
26
+ }
27
+ /** For every project-scoped command except `agents`: returns the config
28
+ * that's already on disk, or runs the interactive prompt the first time --
29
+ * either way, makes sure the daemon covers this project afterward. This is
30
+ * the function wired into Deps.ensureAgentConfig (see index.ts).
31
+ *
32
+ * `ensureDaemonRunningFn` is injectable -- defaulting to the real
33
+ * ensureDaemonRunning, which registers a REAL OS service -- so tests can
34
+ * verify this composes registration correctly without ever touching the
35
+ * real launchctl/systemctl. See daemon.ts/daemonService.ts for the same
36
+ * pattern one layer down. */
37
+ export async function ensureAgentConfigWithDaemon(projectId, root, ensureDaemonRunningFn = ensureDaemonRunning) {
38
+ const config = await ensureAgentConfig(projectId);
39
+ registerProject(projectId, root);
40
+ warnIfDaemonUnsupported(ensureDaemonRunningFn);
41
+ return config;
42
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The single axios instance used for every call to the resumecontext cloud
3
+ * API (see httpCloudApi.ts) -- all 401 handling lives here, once, instead
4
+ * of in every command. A 401 always means the same thing (the token isn't
5
+ * good, whether because no session was ever established or because it
6
+ * expired) and always has the same fix (`resumecontext auth`), so this is
7
+ * the one place that clears local credentials and picks the right message
8
+ * for which of those two cases the user is actually in.
9
+ *
10
+ * Other error responses are propagated as a plain Error carrying whatever
11
+ * message the API sent -- no custom error class (see cloudApi.ts's module
12
+ * doc): the message text IS the UI. When an HTTP status is available it is
13
+ * also attached on the error object (see apiErrorStatus) so callers can
14
+ * distinguish failure kinds programmatically; display code still just uses
15
+ * the message.
16
+ */
17
+ import axios from "axios";
18
+ import { API_REQUEST_TIMEOUT_MS } from "./constants.js";
19
+ import { readCredentials, clearCredentials } from "./session.js";
20
+ export const apiClient = axios.create({
21
+ baseURL: process.env.RESUMECONTEXT_API_URL || "https://api.resumecontext.com",
22
+ timeout: API_REQUEST_TIMEOUT_MS,
23
+ });
24
+ function messageFromResponseBody(data, fallback) {
25
+ if (data && typeof data === "object" && "message" in data && typeof data.message === "string") {
26
+ return data.message;
27
+ }
28
+ return fallback;
29
+ }
30
+ function apiError(message, status) {
31
+ return Object.assign(new Error(message), { status });
32
+ }
33
+ export function apiErrorStatus(err) {
34
+ return err instanceof Error ? err.status : undefined;
35
+ }
36
+ apiClient.interceptors.response.use((response) => response, (error) => {
37
+ if (axios.isAxiosError(error)) {
38
+ if (error.response) {
39
+ const { status, data } = error.response;
40
+ if (status === 401) {
41
+ const hadCredentials = readCredentials() !== null;
42
+ clearCredentials();
43
+ throw apiError(hadCredentials
44
+ ? "Your session has expired. Run `resumecontext auth` again."
45
+ : "You're not logged in. Run `resumecontext auth` first.", 401);
46
+ }
47
+ throw apiError(messageFromResponseBody(data, error.message), status);
48
+ }
49
+ // Request was made but no response came back at all (offline, DNS
50
+ // failure, connection refused, timeout).
51
+ throw new Error("Couldn't reach resumecontext.com. Check your connection and try again.");
52
+ }
53
+ throw error;
54
+ });
@@ -0,0 +1,20 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Best-effort -- opens the user's default browser to `url`. Never throws:
3
+ * the CLI always prints the URL first, so a failure here just means the
4
+ * user opens it by hand instead of automatically. */
5
+ export function openInBrowser(url) {
6
+ try {
7
+ if (process.platform === "darwin") {
8
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
9
+ }
10
+ else if (process.platform === "win32") {
11
+ spawn("cmd", ["/c", "start", '""', url], { stdio: "ignore", detached: true, shell: true }).unref();
12
+ }
13
+ else {
14
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
15
+ }
16
+ }
17
+ catch {
18
+ // fine -- URL was already printed
19
+ }
20
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The contract between this CLI and the resumecontext cloud service. Every
3
+ * command depends on this interface, never on a concrete implementation --
4
+ * that's what makes both real usage (HttpCloudApi, an axios-backed client
5
+ * in httpCloudApi.ts, pointed at the real backend/ over HTTP) and hermetic
6
+ * tests possible without the CLI code itself knowing which one it's talking
7
+ * to. Tests point that same HttpCloudApi at test/fakeBackendServer.ts's
8
+ * embedded HTTP server instead -- so it's real HTTP either way, including
9
+ * real status codes and a real 401 flow (see apiClient.ts).
10
+ *
11
+ * Failures are plain `Error`s with a user-facing message -- no custom error
12
+ * class. index.ts's top-level handler prints whatever message reaches it,
13
+ * so the message text IS the UI; write it accordingly at the throw site.
14
+ */
15
+ export {};
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `resumecontext accept` -- accepts the caller's own pending invite for the
3
+ * project marked in this directory. Requires `init` to have been run here
4
+ * first (init always writes the marker, even in the pending_invite case), so
5
+ * the explicit "no marker" failure this goal calls out by name is just
6
+ * requireProject()'s normal NO_PROJECT_HERE error.
7
+ */
8
+ import { requireProject } from "../projectRoot.js";
9
+ import { requireCredentials } from "../session.js";
10
+ import * as ui from "../ui.js";
11
+ export async function runAccept(deps) {
12
+ ui.intro("accept");
13
+ const { token } = requireCredentials();
14
+ const { root, projectId } = requireProject(deps.cwd);
15
+ await deps.ensureAgentConfig(projectId, root);
16
+ const { mcpUrl } = await ui.withSpinner("Accepting invite...", "Accepted.", () => deps.cloudApi.acceptInvite(token, projectId));
17
+ ui.log.info(`MCP URL: ${mcpUrl}`);
18
+ ui.outro("Run `resumecontext mcp` for the URL plus the token your agent needs, then `resumecontext sync` any time.");
19
+ return { mcpUrl };
20
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `resumecontext agents` -- reconfigure which coding agents this CLI scans
3
+ * for local history for THIS project, and where their data lives. The
4
+ * config is per-project (see agentConfig.ts), so like every other
5
+ * project-scoped command this needs `init` to have been run here first.
6
+ */
7
+ import { requireProject } from "../projectRoot.js";
8
+ import { requireCredentials } from "../session.js";
9
+ import { promptForAgentConfig, readAgentConfig } from "../agentConfig.js";
10
+ import { AGENT_ADAPTERS } from "../localHistory/registry.js";
11
+ import * as ui from "../ui.js";
12
+ export async function runAgents(deps) {
13
+ ui.intro("agents");
14
+ requireCredentials();
15
+ const { projectId } = requireProject(deps.cwd);
16
+ const existing = readAgentConfig(projectId);
17
+ if (existing && Object.keys(existing.agents).length > 0) {
18
+ ui.log.info("Currently syncing from:");
19
+ for (const [name, cfg] of Object.entries(existing.agents)) {
20
+ ui.log.info(` ${AGENT_ADAPTERS[name].label}`);
21
+ for (const dir of cfg.dirs)
22
+ ui.log.info(` ${dir}`);
23
+ }
24
+ }
25
+ // Deliberately does NOT register the project or ensure the daemon is
26
+ // running: `init` already registered it (this command requires a project,
27
+ // and init is the only way to get one), and the daemon re-reads each
28
+ // project's agent config at the top of every tick -- so a running daemon
29
+ // picks this up within seconds on its own. Ensuring the daemon here would
30
+ // also silently undo an explicit `daemon stop`, which should stay stopped
31
+ // until the user runs something that actually needs to sync.
32
+ const config = await promptForAgentConfig(projectId, existing);
33
+ ui.outro("Saved.");
34
+ return config;
35
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `resumecontext auth` -- browser-based login. The CLI mints a device code,
3
+ * shows/opens a link to resumecontext.com, and polls until the user has
4
+ * signed in (or signed up -- same page, same flow) over there. No password
5
+ * or email ever passes through the CLI itself.
6
+ */
7
+ import { AUTH_POLL_INTERVAL_MS, DEVICE_CODE_TTL_MS } from "../constants.js";
8
+ import { readCredentials, writeCredentials } from "../session.js";
9
+ import { openInBrowser } from "../browser.js";
10
+ import { sleep } from "../utils.js";
11
+ import * as ui from "../ui.js";
12
+ import { ensureDaemonRunning } from "../daemon.js";
13
+ function startDaemonAfterAuth(ensureDaemonRunningFn) {
14
+ const { platform } = ensureDaemonRunningFn();
15
+ if (platform === "unsupported") {
16
+ ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
17
+ }
18
+ }
19
+ async function pollForLogin(deps, deviceCode, pollIntervalMs, timeoutMs) {
20
+ const deadline = Date.now() + timeoutMs;
21
+ while (Date.now() < deadline) {
22
+ const result = await deps.cloudApi.pollDeviceLogin(deviceCode);
23
+ if (result.status === "complete")
24
+ return { token: result.token, email: result.email };
25
+ if (result.status === "expired")
26
+ throw new Error("Didn't finish signing in in the browser. Run `resumecontext auth` again.");
27
+ await sleep(pollIntervalMs);
28
+ }
29
+ throw new Error("Didn't finish signing in in the browser. Run `resumecontext auth` again.");
30
+ }
31
+ export async function runAuth(deps, opts = {}) {
32
+ ui.intro("auth");
33
+ // Logging in again while a session already exists would silently replace
34
+ // it -- surprising if the user just forgot they were logged in, and
35
+ // confusing if they meant to switch accounts (the old token stays valid
36
+ // server-side). Make them log out explicitly instead. Not an error: this
37
+ // is the "you're already all set" case, so exit 0.
38
+ const existing = readCredentials();
39
+ if (existing) {
40
+ startDaemonAfterAuth(opts.ensureDaemonRunning ?? ensureDaemonRunning);
41
+ ui.log.info(`You're already logged in as ${existing.email}.`);
42
+ ui.outro("Run `resumecontext logout` first if you want to switch accounts.");
43
+ return { email: existing.email };
44
+ }
45
+ const { verificationUrl, deviceCode } = await deps.cloudApi.startDeviceLogin();
46
+ opts.onVerificationUrl?.(verificationUrl);
47
+ ui.log.info("Opening your browser to sign in (or create an account) at resumecontext.com...");
48
+ ui.log.info(`If it doesn't open automatically, visit:\n ${verificationUrl}`);
49
+ (opts.openBrowser ?? openInBrowser)(verificationUrl);
50
+ const { token, email } = await ui.withSpinner("Waiting for you to finish signing in in your browser...", "Signed in.", () => pollForLogin(deps, deviceCode, opts.pollIntervalMs ?? AUTH_POLL_INTERVAL_MS, opts.timeoutMs ?? DEVICE_CODE_TTL_MS));
51
+ writeCredentials({ token, email });
52
+ startDaemonAfterAuth(opts.ensureDaemonRunning ?? ensureDaemonRunning);
53
+ ui.outro(`Logged in as ${email}.`);
54
+ return { email };
55
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `resumecontext daemon status` / `start` / `stop` -- inspect, (re)enable,
3
+ * or turn off the background auto-sync schedule. Separate from every other
4
+ * command: this doesn't operate on a specific project (once scheduled, it
5
+ * covers every configured project on this machine at once), so unlike
6
+ * project-scoped commands it needs no marker and no login.
7
+ *
8
+ * `stop` exists because installPersistentService (see daemonService.ts)
9
+ * registers a real OS-managed schedule (launchd StartInterval / a systemd
10
+ * timer) -- once that's in place, there needs to be an equally real way to
11
+ * turn it back off, not just killing whatever tick happens to be running
12
+ * (the OS would just fire the next one on schedule regardless).
13
+ *
14
+ * `start` exists for the reverse: every project-scoped command already
15
+ * calls ensureDaemonRunning() on its own (see agentConfigWithDaemon.ts), so
16
+ * this is rarely needed day to day -- it's here for after an explicit
17
+ * `stop`, or to check "did that actually work" without waiting for the
18
+ * next `sync`. */
19
+ import { isServiceActive, installPersistentService, uninstallPersistentService, detectPlatform } from "../daemonService.js";
20
+ import { readRegistry } from "../daemon.js";
21
+ import { findProjectRoot } from "../projectRoot.js";
22
+ import * as ui from "../ui.js";
23
+ /** Resolve each registry entry exactly as a daemon tick does, so status can
24
+ * show both what is registered and whether it is currently eligible to sync. */
25
+ export function registeredProjectStatuses() {
26
+ return Object.entries(readRegistry().projects).map(([projectId, { root }]) => {
27
+ const resolved = findProjectRoot(root);
28
+ if (!resolved.projectId)
29
+ return { projectId, root, state: "missing_marker" };
30
+ if (resolved.projectId !== projectId) {
31
+ return {
32
+ projectId,
33
+ root,
34
+ state: "mismatched_marker",
35
+ markerRoot: resolved.root,
36
+ markerProjectId: resolved.projectId,
37
+ };
38
+ }
39
+ return { projectId, root, state: "ready", markerRoot: resolved.root };
40
+ });
41
+ }
42
+ export async function runDaemonStatus() {
43
+ ui.intro("daemon status");
44
+ const platform = detectPlatform();
45
+ const running = isServiceActive();
46
+ if (running) {
47
+ ui.log.success(`Auto-sync is running (registered with ${platform === "launchd" ? "launchd" : "systemd"} -- ` +
48
+ "survives crashes and reboots).");
49
+ }
50
+ else if (platform === "unsupported") {
51
+ ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
52
+ }
53
+ else {
54
+ ui.log.info("Auto-sync is not running. It starts again after `resumecontext auth` or a project command like `sync`.");
55
+ }
56
+ const projects = registeredProjectStatuses();
57
+ if (projects.length === 0) {
58
+ ui.log.info("No projects are registered for background sync yet.");
59
+ }
60
+ else {
61
+ ui.log.info(`Background sync projects (${projects.length}):`);
62
+ for (const project of projects) {
63
+ if (project.state === "ready") {
64
+ const moved = project.markerRoot !== project.root ? ` (marker at ${project.markerRoot})` : "";
65
+ ui.log.info(` ${project.projectId}\n ${project.root}${moved}`);
66
+ }
67
+ else if (project.state === "missing_marker") {
68
+ ui.log.warn(` ${project.projectId} — skipped: no .resumecontext.json at ${project.root} or its parents`);
69
+ }
70
+ else {
71
+ ui.log.warn(` ${project.projectId} — skipped: marker at ${project.markerRoot} belongs to ${project.markerProjectId}`);
72
+ }
73
+ }
74
+ }
75
+ ui.outro(running ? "Running." : "Not running.");
76
+ return { running, projects };
77
+ }
78
+ export async function runDaemonStart() {
79
+ ui.intro("daemon start");
80
+ const platform = detectPlatform();
81
+ if (platform === "unsupported") {
82
+ ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
83
+ ui.outro("Not available.");
84
+ return { started: false };
85
+ }
86
+ const { installed } = installPersistentService();
87
+ ui.outro(installed
88
+ ? `Auto-sync started (registered with ${platform === "launchd" ? "launchd" : "systemd"} -- survives crashes and reboots).`
89
+ : "Auto-sync was already running.");
90
+ return { started: installed };
91
+ }
92
+ export async function runDaemonStop() {
93
+ ui.intro("daemon stop");
94
+ const { uninstalled } = uninstallPersistentService();
95
+ ui.outro(uninstalled
96
+ ? "Auto-sync stopped. It starts again after `resumecontext auth` or a project command like `sync`."
97
+ : "Auto-sync wasn't running.");
98
+ return { stopped: uninstalled };
99
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `resumecontext init` -- the flagship command, and the only one that prints
3
+ * the big ASCII banner (per the goal: "When init is run, it should print out
4
+ * the name of the app... in big and nice ascii art").
5
+ *
6
+ * Marks the EXACT directory init is run in (never auto-redirects to an
7
+ * enclosing git root) -- see projectRoot.ts's module doc for why: it's what
8
+ * lets a monorepo subfolder become its own project. Re-running init in a
9
+ * directory that already has its own marker is a no-op-ish "already set up"
10
+ * confirmation, not an error.
11
+ *
12
+ * Project identity is ONLY ever the local marker's projectId -- nothing is
13
+ * derived from git remotes or file paths. So: no marker in this exact
14
+ * directory means create a new project; a marker here (written by a prior
15
+ * `init`, or because the marker file was committed to git and this is a
16
+ * fresh clone of a project someone else already registered) means look that
17
+ * project up and report this caller's relationship to it.
18
+ */
19
+ import path from "node:path";
20
+ import { findProjectRoot, writeMarker } from "../projectRoot.js";
21
+ import { requireCredentials } from "../session.js";
22
+ import * as ui from "../ui.js";
23
+ export async function runInit(deps) {
24
+ ui.printBanner();
25
+ ui.intro("init");
26
+ const { token } = requireCredentials();
27
+ const cwd = path.resolve(deps.cwd);
28
+ const existing = findProjectRoot(cwd);
29
+ const existingProjectId = existing.projectId && existing.root === cwd ? existing.projectId : null;
30
+ const outcome = await ui.withSpinner("Talking to resumecontext.com...", "Done.", async () => {
31
+ if (existingProjectId)
32
+ return deps.cloudApi.findProject(token, existingProjectId);
33
+ const created = await deps.cloudApi.createProject(token, path.basename(cwd));
34
+ return { status: "created", ...created };
35
+ });
36
+ // Every outcome gets a local marker -- accept (and any other project-scoped
37
+ // command) requires one to exist, even before a pending invite is accepted.
38
+ writeMarker(cwd, outcome.projectId);
39
+ // Only now, once the project has an id, can its agent config be looked up
40
+ // or prompted for -- the config is keyed by that id (see agentConfig.ts).
41
+ await deps.ensureAgentConfig(outcome.projectId, cwd);
42
+ switch (outcome.status) {
43
+ case "created":
44
+ ui.log.success(`New project created for ${cwd}`);
45
+ ui.log.info(`MCP URL: ${outcome.mcpUrl}`);
46
+ ui.outro("Run `resumecontext mcp` for the URL plus the token your agent needs, then `resumecontext sync` any time.");
47
+ break;
48
+ case "already_has_access":
49
+ ui.log.info("This project is already set up.");
50
+ ui.log.info(`MCP URL: ${outcome.mcpUrl}`);
51
+ ui.outro("Run `resumecontext mcp` to configure your agent, or `resumecontext sync` to push the latest local context.");
52
+ break;
53
+ case "pending_invite":
54
+ ui.log.warn(`You have a pending invite for this project as ${outcome.invitedEmail}.`);
55
+ ui.outro("Run `resumecontext accept` to accept it.");
56
+ break;
57
+ }
58
+ return outcome;
59
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `resumecontext logout` -- clears this machine's stored session so a
3
+ * different account can log in. Local only: it does not revoke the token
4
+ * server-side, and it deliberately leaves agent config and per-project sync
5
+ * state alone, since those aren't account-specific and re-collecting them
6
+ * after every account switch would be pointless busywork.
7
+ */
8
+ import { readCredentials, clearCredentials } from "../session.js";
9
+ import * as ui from "../ui.js";
10
+ export async function runLogout() {
11
+ ui.intro("logout");
12
+ const existing = readCredentials();
13
+ if (!existing) {
14
+ ui.outro("You're not logged in.");
15
+ return { loggedOut: false };
16
+ }
17
+ clearCredentials();
18
+ ui.outro(`Logged out ${existing.email}.`);
19
+ return { loggedOut: true };
20
+ }