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.
- package/README.md +27 -0
- package/dist/agentConfig.js +202 -0
- package/dist/agentConfigWithDaemon.js +42 -0
- package/dist/apiClient.js +54 -0
- package/dist/browser.js +20 -0
- package/dist/cloudApi.js +15 -0
- package/dist/commands/accept.js +20 -0
- package/dist/commands/agents.js +35 -0
- package/dist/commands/auth.js +55 -0
- package/dist/commands/daemon.js +99 -0
- package/dist/commands/init.js +59 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/mcp.js +54 -0
- package/dist/commands/members.js +20 -0
- package/dist/commands/projects.js +55 -0
- package/dist/commands/revoke.js +15 -0
- package/dist/commands/share.js +18 -0
- package/dist/commands/sync.js +59 -0
- package/dist/commands/uninstall.js +61 -0
- package/dist/constants.js +61 -0
- package/dist/daemon.js +409 -0
- package/dist/daemonService.js +326 -0
- package/dist/deps.js +1 -0
- package/dist/dev.js +32 -0
- package/dist/device.js +40 -0
- package/dist/httpCloudApi.js +61 -0
- package/dist/index.js +160 -0
- package/dist/localCapture.js +18 -0
- package/dist/localHistory/claudeCode.js +82 -0
- package/dist/localHistory/codex.js +106 -0
- package/dist/localHistory/cursor.js +492 -0
- package/dist/localHistory/index.js +96 -0
- package/dist/localHistory/opencode.js +148 -0
- package/dist/localHistory/registry.js +66 -0
- package/dist/localHistory/shared.js +174 -0
- package/dist/paths.js +85 -0
- package/dist/projectRoot.js +77 -0
- package/dist/session.js +36 -0
- package/dist/syncCore.js +108 -0
- package/dist/syncState.js +51 -0
- package/dist/ui.js +289 -0
- package/dist/utils.js +41 -0
- package/dist/version.js +43 -0
- package/package.json +64 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { credentialsFile } from "./paths.js";
|
|
4
|
+
export function readCredentials() {
|
|
5
|
+
try {
|
|
6
|
+
const raw = fs.readFileSync(credentialsFile(), "utf-8");
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
if (typeof parsed?.token === "string" && typeof parsed?.email === "string")
|
|
9
|
+
return parsed;
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function writeCredentials(creds) {
|
|
17
|
+
const file = credentialsFile();
|
|
18
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
fs.writeFileSync(file, JSON.stringify(creds, null, 2));
|
|
20
|
+
}
|
|
21
|
+
export function clearCredentials() {
|
|
22
|
+
try {
|
|
23
|
+
fs.unlinkSync(credentialsFile());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// already gone -- fine
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Every command that needs a token calls this instead of reading
|
|
30
|
+
* credentials directly, so "not logged in" is always the same message. */
|
|
31
|
+
export function requireCredentials() {
|
|
32
|
+
const creds = readCredentials();
|
|
33
|
+
if (!creds)
|
|
34
|
+
throw new Error("You're not logged in. Run `resumecontext auth` first.");
|
|
35
|
+
return creds;
|
|
36
|
+
}
|
package/dist/syncCore.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scan -> diff -> push mechanics shared between the interactive `sync`
|
|
3
|
+
* command (commands/sync.ts) and the background auto-sync daemon
|
|
4
|
+
* (daemon.ts) -- deliberately with no UI/spinner/console dependency, so
|
|
5
|
+
* each caller can wrap it however fits (a spinner with live progress text
|
|
6
|
+
* for one, silent logging for the other).
|
|
7
|
+
*
|
|
8
|
+
* Split into two steps rather than one function, specifically so a caller
|
|
9
|
+
* that only cares about "is there anything new" (the daemon, on every
|
|
10
|
+
* tick) can stop after scanForNewTurns without ever calling
|
|
11
|
+
* pushNewTurns -- that's what makes "only queries the backend if there are
|
|
12
|
+
* new turns" true: pushNewTurns, the only function here that talks to
|
|
13
|
+
* CloudApi, is simply never invoked when newTurns is empty.
|
|
14
|
+
*/
|
|
15
|
+
import { readSyncState, writeSyncState, sessionKey } from "./syncState.js";
|
|
16
|
+
import { PUSH_BATCH_BYTES, PUSH_BATCH_SIZE, SOURCE_SETTLE_MS } from "./constants.js";
|
|
17
|
+
/** Splits `allTurns` (in scan() order -- chronological within a session)
|
|
18
|
+
* into what's new since `turnCountBySession`, plus the counts to persist
|
|
19
|
+
* once a push actually succeeds. Turns are grouped by `agent:sessionId`
|
|
20
|
+
* first (see syncState.ts for why not bare sessionId), so interleaving
|
|
21
|
+
* between sessions in `allTurns` doesn't matter -- only each session's own
|
|
22
|
+
* turn count does. */
|
|
23
|
+
function diffAgainstSyncState(allTurns, turnCountBySession, sessionMtimeMs, nowMs = Date.now()) {
|
|
24
|
+
const bySession = new Map();
|
|
25
|
+
for (const turn of allTurns) {
|
|
26
|
+
const key = sessionKey(turn.agent, turn.sessionId);
|
|
27
|
+
if (!bySession.has(key))
|
|
28
|
+
bySession.set(key, []);
|
|
29
|
+
bySession.get(key).push(turn);
|
|
30
|
+
}
|
|
31
|
+
const newTurns = [];
|
|
32
|
+
const updatedCounts = {};
|
|
33
|
+
for (const [key, turns] of bySession) {
|
|
34
|
+
const alreadySynced = turnCountBySession[key] ?? 0;
|
|
35
|
+
const mtime = sessionMtimeMs[key];
|
|
36
|
+
// A recently-touched source file may still be mid-write; withhold only
|
|
37
|
+
// the trailing turn so earlier turns keep syncing on every tick.
|
|
38
|
+
const hot = mtime !== undefined && nowMs - mtime < SOURCE_SETTLE_MS;
|
|
39
|
+
const syncable = hot ? Math.max(alreadySynced, turns.length - 1) : turns.length;
|
|
40
|
+
for (const turn of turns.slice(alreadySynced, syncable))
|
|
41
|
+
newTurns.push(turn);
|
|
42
|
+
updatedCounts[key] = syncable;
|
|
43
|
+
}
|
|
44
|
+
return { newTurns, updatedCounts };
|
|
45
|
+
}
|
|
46
|
+
/** Scans local history and diffs it against the last successful sync.
|
|
47
|
+
* Never touches the network -- everything here is local disk + a local
|
|
48
|
+
* state file, which is what lets this run on every daemon tick (see
|
|
49
|
+
* daemonService.ts's DAEMON_INTERVAL_MS) without costing a request when
|
|
50
|
+
* nothing changed. */
|
|
51
|
+
export async function scanForNewTurns(localCapture, root, agentConfig, projectId) {
|
|
52
|
+
const { turns: allTurns, sessionMtimeMs } = await localCapture.scan(root, agentConfig);
|
|
53
|
+
const { turnCountBySession } = readSyncState(projectId);
|
|
54
|
+
const { newTurns, updatedCounts } = diffAgainstSyncState(allTurns, turnCountBySession, sessionMtimeMs);
|
|
55
|
+
return { allTurns, newTurns, updatedCounts };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Splits turns into request-sized batches, bounded by BYTES as well as count.
|
|
59
|
+
*
|
|
60
|
+
* Count alone was the bound and it is not one: a batch of 500 turns is about
|
|
61
|
+
* 1MB of ordinary conversation and several hundred MB if it happens to catch
|
|
62
|
+
* a few large tool outputs. See PUSH_BATCH_BYTES.
|
|
63
|
+
*
|
|
64
|
+
* An oversized single turn is emitted alone rather than dropped or split:
|
|
65
|
+
* splitting it would change its content hash and corrupt the transcript, and
|
|
66
|
+
* the server rejects what it cannot accept.
|
|
67
|
+
*/
|
|
68
|
+
export function batchTurns(turns) {
|
|
69
|
+
const batches = [];
|
|
70
|
+
let batch = [];
|
|
71
|
+
let bytes = 0;
|
|
72
|
+
for (const turn of turns) {
|
|
73
|
+
const size = JSON.stringify(turn).length;
|
|
74
|
+
if (batch.length > 0 && (batch.length >= PUSH_BATCH_SIZE || bytes + size > PUSH_BATCH_BYTES)) {
|
|
75
|
+
batches.push(batch);
|
|
76
|
+
batch = [];
|
|
77
|
+
bytes = 0;
|
|
78
|
+
}
|
|
79
|
+
batch.push(turn);
|
|
80
|
+
bytes += size;
|
|
81
|
+
}
|
|
82
|
+
if (batch.length > 0)
|
|
83
|
+
batches.push(batch);
|
|
84
|
+
return batches;
|
|
85
|
+
}
|
|
86
|
+
/** Pushes `newTurns` in batches and advances the local sync-state cursor
|
|
87
|
+
* after each batch -- so a push that fails partway only retries the
|
|
88
|
+
* remaining batches, not everything already accepted (see syncState.ts).
|
|
89
|
+
* Callers should skip calling this entirely when `newTurns` is empty; there
|
|
90
|
+
* is deliberately no short-circuit inside it, so an empty push is a caller
|
|
91
|
+
* bug, not a silent no-op that masks one. */
|
|
92
|
+
export async function pushNewTurns(cloudApi, token, projectId, newTurns) {
|
|
93
|
+
const runningCounts = { ...readSyncState(projectId).turnCountBySession };
|
|
94
|
+
let accepted = 0;
|
|
95
|
+
for (const batch of batchTurns(newTurns)) {
|
|
96
|
+
const result = await cloudApi.pushTurns(token, projectId, batch);
|
|
97
|
+
accepted += result.accepted;
|
|
98
|
+
for (const turn of batch) {
|
|
99
|
+
const key = sessionKey(turn.agent, turn.sessionId);
|
|
100
|
+
runningCounts[key] = (runningCounts[key] ?? 0) + 1;
|
|
101
|
+
}
|
|
102
|
+
// Persist after every batch, not just at the end -- if a later batch in
|
|
103
|
+
// this same call fails, the turns already accepted by the backend stay
|
|
104
|
+
// marked as synced instead of being re-uploaded wholesale next time.
|
|
105
|
+
writeSyncState(projectId, { turnCountBySession: runningCounts });
|
|
106
|
+
}
|
|
107
|
+
return { accepted };
|
|
108
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local, per-project record of how much of each coding-agent session has
|
|
3
|
+
* already been pushed to the cloud -- one turn count per session, treated
|
|
4
|
+
* like a consumer offset into that session's turn list. `LocalCapture.scan`
|
|
5
|
+
* always returns a session's turns in chronological order (ingest/ sorts
|
|
6
|
+
* the whole project's turns by timestamp before writing them out, and that
|
|
7
|
+
* sort is stable, so a session's own turns never reorder between runs --
|
|
8
|
+
* only grow), so "already synced the first N" is a safe, simple cursor:
|
|
9
|
+
* next time, skip the first N turns of that session and send the rest.
|
|
10
|
+
*
|
|
11
|
+
* Sessions are keyed by `agent:sessionId`, not bare sessionId -- each agent
|
|
12
|
+
* (claude-code, cursor, codex, ...) generates its own session ids
|
|
13
|
+
* independently, with no coordination between them, so a bare sessionId
|
|
14
|
+
* isn't guaranteed unique across agents. Namespacing by agent rules that
|
|
15
|
+
* out structurally instead of relying on it being unlikely in practice.
|
|
16
|
+
*
|
|
17
|
+
* This lets `sync` upload only what's new since the last successful sync
|
|
18
|
+
* instead of re-pushing a project's entire history every time. The server
|
|
19
|
+
* still dedupes idempotently on top of this (see backend/projects/store.ts's
|
|
20
|
+
* pushTurns) -- that's the backstop for a push that partially failed, a
|
|
21
|
+
* wiped `~/.resumecontext`, or syncing the same project from two machines;
|
|
22
|
+
* this file is what makes the common case cheap.
|
|
23
|
+
*/
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { syncStateFile } from "./paths.js";
|
|
27
|
+
/** The one place `agent:sessionId` keys get constructed, so sync.ts's
|
|
28
|
+
* grouping and this file's stored keys can never drift apart in format. */
|
|
29
|
+
export function sessionKey(agent, sessionId) {
|
|
30
|
+
return `${agent}:${sessionId}`;
|
|
31
|
+
}
|
|
32
|
+
function emptyState() {
|
|
33
|
+
return { turnCountBySession: {} };
|
|
34
|
+
}
|
|
35
|
+
export function readSyncState(projectId) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(syncStateFile(projectId), "utf-8");
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
if (parsed && typeof parsed.turnCountBySession === "object")
|
|
40
|
+
return parsed;
|
|
41
|
+
return emptyState();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return emptyState();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function writeSyncState(projectId, state) {
|
|
48
|
+
const file = syncStateFile(projectId);
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
51
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every command routes its output through here rather than calling
|
|
3
|
+
* console.log/@clack/prompts directly -- keeps the visual language (colors,
|
|
4
|
+
* symbols, wording) consistent across commands, and gives tests one place to
|
|
5
|
+
* silence output (see test/helpers.ts) instead of mocking console per-file.
|
|
6
|
+
*/
|
|
7
|
+
import * as clack from "@clack/prompts";
|
|
8
|
+
import { Prompt, isCancel } from "@clack/core";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import figlet from "figlet";
|
|
11
|
+
import gradient from "gradient-string";
|
|
12
|
+
const brand = gradient(["#7C3AED", "#06B6D4"]); // violet -> cyan
|
|
13
|
+
/** The big ASCII banner shown at the start of `init`. Falls back to a
|
|
14
|
+
* simpler styled line on narrow terminals so it never wraps into a mess. */
|
|
15
|
+
export function printBanner() {
|
|
16
|
+
const columns = process.stdout.columns ?? 80;
|
|
17
|
+
const text = columns >= 70 ? "ResumeContext" : "RESUMECONTEXT";
|
|
18
|
+
const font = columns >= 70 ? "Slant" : "Small";
|
|
19
|
+
try {
|
|
20
|
+
const art = figlet.textSync(text, { font });
|
|
21
|
+
console.log(brand.multiline(art));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// figlet font data missing/unsupported terminal -- degrade gracefully,
|
|
25
|
+
// this is decoration, never something worth crashing a command over.
|
|
26
|
+
console.log(brand("ResumeContext"));
|
|
27
|
+
}
|
|
28
|
+
console.log(chalk.dim(" resumecontext.com — shared context for coding agents\n"));
|
|
29
|
+
}
|
|
30
|
+
export function intro(title) {
|
|
31
|
+
clack.intro(chalk.bgCyan.black(` ${title} `));
|
|
32
|
+
}
|
|
33
|
+
export function outro(message) {
|
|
34
|
+
clack.outro(chalk.green(message));
|
|
35
|
+
}
|
|
36
|
+
export const log = {
|
|
37
|
+
info: (msg) => clack.log.info(msg),
|
|
38
|
+
success: (msg) => clack.log.success(chalk.green(msg)),
|
|
39
|
+
warn: (msg) => clack.log.warn(chalk.yellow(msg)),
|
|
40
|
+
step: (msg) => clack.log.step(msg),
|
|
41
|
+
};
|
|
42
|
+
export function spinner() {
|
|
43
|
+
return clack.spinner();
|
|
44
|
+
}
|
|
45
|
+
/** Runs `fn` under a spinner, always stopping it -- with `stopMessage` on
|
|
46
|
+
* success, or a "Failed." message before rethrowing on error. A bare
|
|
47
|
+
* `spin.start()`/`spin.stop()` pair left the spinner's interval running
|
|
48
|
+
* forever on a thrown error (the process would hang indefinitely instead of
|
|
49
|
+
* exiting with the error), so every command routes spinner usage through
|
|
50
|
+
* here rather than managing start/stop by hand. */
|
|
51
|
+
export async function withSpinner(startMessage, stopMessage, fn) {
|
|
52
|
+
const spin = spinner();
|
|
53
|
+
spin.start(startMessage);
|
|
54
|
+
try {
|
|
55
|
+
const result = await fn();
|
|
56
|
+
spin.stop(stopMessage);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
spin.stop("Failed.");
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function confirm(message, initialValue = true) {
|
|
65
|
+
const result = await clack.confirm({ message, initialValue });
|
|
66
|
+
if (clack.isCancel(result)) {
|
|
67
|
+
clack.cancel("Cancelled.");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
export async function promptText(message, opts) {
|
|
73
|
+
const result = await clack.text({
|
|
74
|
+
message,
|
|
75
|
+
placeholder: opts?.placeholder,
|
|
76
|
+
initialValue: opts?.initialValue,
|
|
77
|
+
validate: opts?.validate,
|
|
78
|
+
});
|
|
79
|
+
if (clack.isCancel(result)) {
|
|
80
|
+
clack.cancel("Cancelled.");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The picker behind `multiselect`. Built on @clack/core's base Prompt
|
|
87
|
+
* rather than its MultiSelectPrompt because both of that class's key
|
|
88
|
+
* bindings are wrong for this UI: it toggles on SPACE and submits on
|
|
89
|
+
* ENTER, whereas Enter is the key people actually reach for to pick the
|
|
90
|
+
* thing under the cursor. So here Enter toggles, space does nothing, and
|
|
91
|
+
* submitting is its own explicitly-labelled row at the bottom of the list
|
|
92
|
+
* -- reachable by the same arrow-keys-then-Enter motion as everything
|
|
93
|
+
* else, so there's exactly one interaction to learn.
|
|
94
|
+
*
|
|
95
|
+
* The extra row lives at index `options.length`, one past the real
|
|
96
|
+
* options, which is what `onContinueRow` tests for.
|
|
97
|
+
*/
|
|
98
|
+
class TogglePicker extends Prompt {
|
|
99
|
+
options;
|
|
100
|
+
cursor = 0;
|
|
101
|
+
constructor(opts) {
|
|
102
|
+
// trackValue: false -- this prompt reads keystrokes as commands, not as
|
|
103
|
+
// text being typed into a value.
|
|
104
|
+
super(opts, false);
|
|
105
|
+
this.options = opts.options;
|
|
106
|
+
this.value = [...opts.initialValues];
|
|
107
|
+
const rowCount = this.options.length + 1; // + the Continue row
|
|
108
|
+
this.on("cursor", (key) => {
|
|
109
|
+
// Deliberately no "space" case: toggling is Enter-only here.
|
|
110
|
+
if (key === "up" || key === "left")
|
|
111
|
+
this.cursor = (this.cursor - 1 + rowCount) % rowCount;
|
|
112
|
+
else if (key === "down" || key === "right")
|
|
113
|
+
this.cursor = (this.cursor + 1) % rowCount;
|
|
114
|
+
});
|
|
115
|
+
this.on("key", (_char, key) => {
|
|
116
|
+
// Fires before the base class checks _shouldSubmit, so an Enter on an
|
|
117
|
+
// option row toggles it and then declines to submit.
|
|
118
|
+
if (key?.name === "return" && !this.onContinueRow)
|
|
119
|
+
this.toggleCurrent();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
get onContinueRow() {
|
|
123
|
+
return this.cursor === this.options.length;
|
|
124
|
+
}
|
|
125
|
+
get selected() {
|
|
126
|
+
return this.value ?? [];
|
|
127
|
+
}
|
|
128
|
+
toggleCurrent() {
|
|
129
|
+
const target = this.options[this.cursor].value;
|
|
130
|
+
this.value = this.selected.includes(target)
|
|
131
|
+
? this.selected.filter((v) => v !== target)
|
|
132
|
+
: [...this.selected, target];
|
|
133
|
+
}
|
|
134
|
+
/** Enter only submits from the Continue row; everywhere else it toggles. */
|
|
135
|
+
_shouldSubmit() {
|
|
136
|
+
return this.onContinueRow;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A multi-select with a deliberately loud rendering and an explicit
|
|
141
|
+
* Continue row, rather than @clack/prompts' stock `multiselect`. The stock
|
|
142
|
+
* one distinguishes on/off only by a green vs. plain checkbox glyph, which
|
|
143
|
+
* is easy to misread -- and tells you nothing when every row happens to be
|
|
144
|
+
* selected, since there's no unselected row to contrast against. Here each
|
|
145
|
+
* row spells out its state in words ("will sync" / "skipped"), so the list
|
|
146
|
+
* is unambiguous at a glance and stays unambiguous when uniform.
|
|
147
|
+
*/
|
|
148
|
+
export async function multiselect(message, options, initialValues = [],
|
|
149
|
+
/** Defaults to the real stdin/stdout. Overridable so tests can drive the
|
|
150
|
+
* prompt with scripted keystrokes -- this is a hand-written interaction,
|
|
151
|
+
* so its toggle/submit behavior needs to be verifiable, not just its
|
|
152
|
+
* rendering. */
|
|
153
|
+
io = {}) {
|
|
154
|
+
const BAR = chalk.gray("│");
|
|
155
|
+
const labelWidth = Math.max(...options.map((o) => o.label.length));
|
|
156
|
+
const orderedSelection = (chosen) => options.filter((o) => chosen.has(o.value)).map((o) => o.value);
|
|
157
|
+
const prompt = new TogglePicker({
|
|
158
|
+
options,
|
|
159
|
+
initialValues,
|
|
160
|
+
input: io.input,
|
|
161
|
+
output: io.output,
|
|
162
|
+
render() {
|
|
163
|
+
const chosen = new Set(this.selected);
|
|
164
|
+
if (this.state === "submit") {
|
|
165
|
+
const picked = options.filter((o) => chosen.has(o.value)).map((o) => o.label);
|
|
166
|
+
const summary = picked.length > 0 ? chalk.green(picked.join(", ")) : chalk.yellow("none");
|
|
167
|
+
return `${chalk.green("◇")} ${message}\n${BAR} ${summary}`;
|
|
168
|
+
}
|
|
169
|
+
if (this.state === "cancel") {
|
|
170
|
+
return `${chalk.red("■")} ${message}\n${BAR} ${chalk.red("Cancelled.")}`;
|
|
171
|
+
}
|
|
172
|
+
const rows = options.map((opt, i) => {
|
|
173
|
+
const on = chosen.has(opt.value);
|
|
174
|
+
const active = i === this.cursor;
|
|
175
|
+
const pointer = active ? chalk.cyan("❯") : " ";
|
|
176
|
+
const box = on ? chalk.green.bold("[✔]") : chalk.dim("[ ]");
|
|
177
|
+
const state = on ? chalk.green.bold("will sync") : chalk.dim("skipped ");
|
|
178
|
+
const label = on ? chalk.bold(opt.label.padEnd(labelWidth)) : chalk.dim(opt.label.padEnd(labelWidth));
|
|
179
|
+
const hint = opt.hint ? chalk.dim(` · ${opt.hint}`) : "";
|
|
180
|
+
return `${BAR} ${pointer} ${box} ${label} ${state}${hint}`;
|
|
181
|
+
});
|
|
182
|
+
const count = chosen.size;
|
|
183
|
+
const continueText = count > 0 ? `Continue with ${count} selected` : "Continue without syncing any agent";
|
|
184
|
+
const continueRow = this.onContinueRow
|
|
185
|
+
? `${BAR} ${chalk.cyan("❯")} ${chalk.bgCyan.black(` ${continueText} `)}`
|
|
186
|
+
: `${BAR} ${chalk.dim(continueText)}`;
|
|
187
|
+
const help = this.onContinueRow
|
|
188
|
+
? "↑/↓ to move · enter to finish"
|
|
189
|
+
: "↑/↓ to move · enter to select / unselect";
|
|
190
|
+
return [
|
|
191
|
+
`${chalk.cyan("◆")} ${message}`,
|
|
192
|
+
...rows,
|
|
193
|
+
`${BAR}`,
|
|
194
|
+
continueRow,
|
|
195
|
+
`${BAR}`,
|
|
196
|
+
`${BAR} ${chalk.dim(help)}`,
|
|
197
|
+
].join("\n");
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const result = await prompt.prompt();
|
|
201
|
+
if (isCancel(result)) {
|
|
202
|
+
clack.cancel("Cancelled.");
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
// Return in the order the options were declared, not the order they were
|
|
206
|
+
// clicked, so callers get a stable result.
|
|
207
|
+
return orderedSelection(new Set((result ?? [])));
|
|
208
|
+
}
|
|
209
|
+
/** A single-choice menu: arrows move, Enter picks the highlighted row.
|
|
210
|
+
* Same motion as `multiselect`, so the two read as one interaction model. */
|
|
211
|
+
class MenuPrompt extends Prompt {
|
|
212
|
+
rows;
|
|
213
|
+
cursor = 0;
|
|
214
|
+
constructor(opts) {
|
|
215
|
+
super(opts, false);
|
|
216
|
+
this.rows = opts.rows;
|
|
217
|
+
this.cursor = Math.max(this.rows.findIndex((r) => r.value === opts.cursorAt), 0);
|
|
218
|
+
this.value = this.rows[this.cursor]?.value;
|
|
219
|
+
this.on("cursor", (key) => {
|
|
220
|
+
const n = this.rows.length;
|
|
221
|
+
if (key === "up" || key === "left")
|
|
222
|
+
this.cursor = (this.cursor - 1 + n) % n;
|
|
223
|
+
else if (key === "down" || key === "right")
|
|
224
|
+
this.cursor = (this.cursor + 1) % n;
|
|
225
|
+
this.value = this.rows[this.cursor]?.value;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Prompts once and resolves with the chosen row's value. Callers that need
|
|
231
|
+
* a multi-step screen (add an item, remove one, move on) drive it in a
|
|
232
|
+
* loop, re-rendering with updated rows each time -- see agentConfig.ts's
|
|
233
|
+
* directory editor. Intermediate rounds render nothing on submit so the
|
|
234
|
+
* loop doesn't leave a stack of near-identical frames behind; only the
|
|
235
|
+
* caller's own final summary remains.
|
|
236
|
+
*/
|
|
237
|
+
export async function menu(message, rows, opts = {}) {
|
|
238
|
+
const BAR = chalk.gray("│");
|
|
239
|
+
const prompt = new MenuPrompt({
|
|
240
|
+
rows,
|
|
241
|
+
cursorAt: opts.cursorAt,
|
|
242
|
+
input: opts.io?.input,
|
|
243
|
+
output: opts.io?.output,
|
|
244
|
+
render() {
|
|
245
|
+
if (this.state === "submit") {
|
|
246
|
+
if (!opts.keepFrame)
|
|
247
|
+
return "";
|
|
248
|
+
const picked = rows.find((r) => r.value === this.value);
|
|
249
|
+
return `${chalk.green("◇")} ${message}\n${BAR} ${chalk.green(picked?.label ?? "")}`;
|
|
250
|
+
}
|
|
251
|
+
if (this.state === "cancel") {
|
|
252
|
+
return `${chalk.red("■")} ${message}\n${BAR} ${chalk.red("Cancelled.")}`;
|
|
253
|
+
}
|
|
254
|
+
const lines = rows.flatMap((row, i) => {
|
|
255
|
+
const active = i === this.cursor;
|
|
256
|
+
const pointer = active ? chalk.cyan("❯") : " ";
|
|
257
|
+
const base = row.tone === "warn" ? chalk.yellow : row.tone === "action" ? chalk.cyan : (s) => s;
|
|
258
|
+
const label = active ? chalk.bold(base(row.label)) : row.tone === "item" ? base(row.label) : chalk.dim(base(row.label));
|
|
259
|
+
const hint = row.hint ? chalk.dim(` ${row.hint}`) : "";
|
|
260
|
+
const line = `${BAR} ${pointer} ${label}${hint}`;
|
|
261
|
+
return row.separated ? [`${BAR}`, line] : [line];
|
|
262
|
+
});
|
|
263
|
+
const help = rows[this.cursor]?.help ?? "↑/↓ to move · enter to choose";
|
|
264
|
+
return [`${chalk.cyan("◆")} ${message}`, ...lines, `${BAR}`, `${BAR} ${chalk.dim(help)}`].join("\n");
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
const result = await prompt.prompt();
|
|
268
|
+
if (isCancel(result)) {
|
|
269
|
+
clack.cancel("Cancelled.");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
/** Renders a small aligned table -- used by `members`. Not a dependency,
|
|
275
|
+
* just enough padding logic for the one shape this CLI needs. */
|
|
276
|
+
export function table(rows, headers) {
|
|
277
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
278
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ");
|
|
279
|
+
const sep = widths.map((w) => "─".repeat(w)).join(" ");
|
|
280
|
+
return [chalk.bold(line(headers)), chalk.dim(sep), ...rows.map((r) => line(r))].join("\n");
|
|
281
|
+
}
|
|
282
|
+
/** Renders any thrown value consistently: just its message, in red. No
|
|
283
|
+
* custom error class or hint field -- every throw site in this CLI writes
|
|
284
|
+
* a message meant to be shown to the user as-is, so this is the ONLY place
|
|
285
|
+
* that presentation is decided. */
|
|
286
|
+
export function printError(err) {
|
|
287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
288
|
+
clack.log.error(chalk.red(message));
|
|
289
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, generic, domain-independent helpers shared across otherwise
|
|
3
|
+
* unrelated files -- nothing here knows about projects, the cloud API, or
|
|
4
|
+
* CLI output. If a function needs to know about any of those, it belongs
|
|
5
|
+
* in the file that owns that concept, not here.
|
|
6
|
+
*/
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
/** Expands a leading `~` and resolves to an absolute path. Paths typed into
|
|
10
|
+
* an interactive prompt never go through the shell, so `~/.claude/projects`
|
|
11
|
+
* would otherwise be taken literally as a directory named "~". */
|
|
12
|
+
export function resolveUserPath(input) {
|
|
13
|
+
const trimmed = input.trim();
|
|
14
|
+
const expanded = trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed;
|
|
15
|
+
return path.resolve(expanded);
|
|
16
|
+
}
|
|
17
|
+
/** Order-preserving de-duplication. */
|
|
18
|
+
export function unique(values) {
|
|
19
|
+
return [...new Set(values)];
|
|
20
|
+
}
|
|
21
|
+
export function slugify(dirPath) {
|
|
22
|
+
return dirPath
|
|
23
|
+
.replace(/\/+$/, "")
|
|
24
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
25
|
+
.replace(/^-+|-+$/g, "");
|
|
26
|
+
}
|
|
27
|
+
export function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
// The WHATWG HTML Living Standard's regex for <input type="email"> --
|
|
31
|
+
// what browsers themselves use to validate the "email" input type. Chosen
|
|
32
|
+
// over a hand-rolled pattern because it's the actual, well-documented
|
|
33
|
+
// standard rather than something arbitrary: permissive enough not to
|
|
34
|
+
// reject real addresses (dots, plus-addressing, hyphenated domains), while
|
|
35
|
+
// still rejecting obviously-malformed input. This is a format check only --
|
|
36
|
+
// it can't confirm the address is real, so it stays paired with real
|
|
37
|
+
// server-side validation (see fakeBackendServer.ts), not a replacement for it.
|
|
38
|
+
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
39
|
+
export function isValidEmail(email) {
|
|
40
|
+
return EMAIL_RE.test(email);
|
|
41
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for "what version of this CLI is this". Reads
|
|
3
|
+
* package.json's version field rather than hardcoding it a second time --
|
|
4
|
+
* used both for `resumecontext --version` and to detect a stale registered
|
|
5
|
+
* daemon service (see daemonService.ts): embedding this in the generated
|
|
6
|
+
* plist/unit text means an in-place version upgrade (same install path, new
|
|
7
|
+
* file contents) is enough to force the service to be reinstalled, even
|
|
8
|
+
* when nothing about the launch command itself changed.
|
|
9
|
+
*
|
|
10
|
+
* Walks upward from this file's own location to find the nearest
|
|
11
|
+
* package.json, rather than assuming a fixed relative depth -- that depth
|
|
12
|
+
* differs between `tsx src/index.ts` (dev) and the compiled `dist/src/*.js`
|
|
13
|
+
* (prod, one directory deeper because tsc nests `src` under `dist`), and
|
|
14
|
+
* walking up is correct under both without needing to know which one this
|
|
15
|
+
* is.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
function findPackageJson(startDir) {
|
|
21
|
+
let dir = startDir;
|
|
22
|
+
while (true) {
|
|
23
|
+
const candidate = path.join(dir, "package.json");
|
|
24
|
+
if (fs.existsSync(candidate))
|
|
25
|
+
return candidate;
|
|
26
|
+
const parent = path.dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
throw new Error("Could not find package.json above " + startDir);
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let cached = null;
|
|
33
|
+
/** Cached after the first call -- the version can't change during a single
|
|
34
|
+
* process's lifetime, and this may be read on every daemon tick's install
|
|
35
|
+
* check. */
|
|
36
|
+
export function cliVersion() {
|
|
37
|
+
if (cached !== null)
|
|
38
|
+
return cached;
|
|
39
|
+
const packageJsonPath = findPackageJson(path.dirname(fileURLToPath(import.meta.url)));
|
|
40
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
41
|
+
cached = pkg.version;
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "resumecontext",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sync your coding-agent sessions to a shared archive your agents can search over MCP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"coding-agent",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"cursor",
|
|
10
|
+
"codex",
|
|
11
|
+
"opencode",
|
|
12
|
+
"context",
|
|
13
|
+
"memory",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://resumecontext.com",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/rishabhpoddar/resumecontext.git",
|
|
20
|
+
"directory": "cli"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/rishabhpoddar/resumecontext/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"resumecontext": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "tsx src/dev.ts",
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"release": "./scripts/release.sh",
|
|
45
|
+
"release:dry": "./scripts/release.sh --dry-run"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@clack/core": "^1.4.3",
|
|
49
|
+
"@clack/prompts": "^1.7.0",
|
|
50
|
+
"axios": "^1.19.0",
|
|
51
|
+
"chalk": "^6.0.0",
|
|
52
|
+
"commander": "^15.0.0",
|
|
53
|
+
"figlet": "^1.11.4",
|
|
54
|
+
"gradient-string": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/figlet": "^1.7.0",
|
|
58
|
+
"@types/gradient-string": "^1.1.6",
|
|
59
|
+
"@types/node": "^26.3.0",
|
|
60
|
+
"tsx": "^4.23.12",
|
|
61
|
+
"typescript": "^7.0.2",
|
|
62
|
+
"vitest": "^4.1.11"
|
|
63
|
+
}
|
|
64
|
+
}
|