renor 0.0.0-stage → 0.2.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/lib/setup.mjs ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * `renor setup`: find the AI apps on this machine, add Renor to each without touching anything else in their config,
3
+ * sign in where the app can do it from the terminal, install the skill, then say plainly what is left, app by app.
4
+ */
5
+ import { CLIENTS } from './clients.mjs'
6
+ import { FIRST_MEMORY_RECALL, FIRST_MEMORY_SAVE, GATEWAY_URL, SKILL_INSTALL_ARGS } from './constants.mjs'
7
+ import { checkCode, cleanupClient, probeGateway, verifyClient } from './verify.mjs'
8
+ import { askAboutSharing, outcomeLine } from './sharing.mjs'
9
+ import { confirm, interactive, which } from './system.mjs'
10
+
11
+ /**
12
+ * @param {object} options
13
+ * @param {boolean} options.yes add without asking first
14
+ * @param {boolean} options.dryRun say what would happen, change nothing
15
+ * @param {boolean} options.login run the sign-ins the apps offer from the terminal
16
+ * @param {boolean} options.skill install the Renor skill
17
+ * @param {boolean} options.verify also have Claude Code save and recall one test memory (the gateway is always probed)
18
+ * @param {boolean} options.force replace a renor entry that points somewhere else
19
+ * @param {string[]|null} options.only limit to these app ids
20
+ */
21
+ export async function setup(options, io = defaultIo()) {
22
+ const { env, out, isTTY } = io
23
+ const paint = painter(out, env)
24
+ const say = (line = '') => out.write(line + '\n')
25
+ const canSignIn = options.login && isTTY
26
+
27
+ say(paint.bold('Renor setup'))
28
+ say(`Adds Renor (${GATEWAY_URL}) to the AI apps on this computer. Each app keeps everything else in its config.`)
29
+ say()
30
+
31
+ const pool = options.only ? CLIENTS.filter((client) => options.only.includes(client.id)) : CLIENTS
32
+ const found = []
33
+ const missing = []
34
+ for (const client of pool) {
35
+ const detection = client.detect({ env })
36
+ if (detection.found) found.push({ client, detection })
37
+ else missing.push(client)
38
+ }
39
+
40
+ if (found.length === 0) {
41
+ say('No supported app was found: Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Pi or VS Code.')
42
+ say('For ChatGPT or Claude in the browser, open https://app.renor.cloud and follow the steps there.')
43
+ return { code: 0, results: [] }
44
+ }
45
+
46
+ say(`Found: ${found.map(({ client, detection }) => client.label + (detection.version ? ` ${detection.version}` : '')).join(', ')}.`)
47
+ if (missing.length > 0) say(paint.dim(`Not found: ${missing.map((client) => client.label).join(', ')}.`))
48
+ say()
49
+
50
+ if (options.dryRun) {
51
+ for (const { client, detection } of found) {
52
+ const now = client.current({ env })
53
+ say(` ${client.label}: ${planFor(client, detection, now, options)}`)
54
+ }
55
+ say()
56
+ say('Dry run: nothing was changed.')
57
+ return { code: 0, results: [] }
58
+ }
59
+
60
+ if (!options.yes && isTTY) {
61
+ const signIns = canSignIn ? ' Your browser opens once for each app that signs in from here; approve Renor there.' : ''
62
+ const yes = await confirm(`Add Renor to ${found.length === 1 ? found[0].client.label : `these ${found.length} apps`}?${signIns} [Y/n] `, io)
63
+ if (!yes) {
64
+ say('Nothing was changed.')
65
+ return { code: 0, results: [] }
66
+ }
67
+ say()
68
+ }
69
+
70
+ const results = []
71
+ for (const { client, detection } of found) {
72
+ try {
73
+ results.push(await setUpOne(client, detection, { ...options, canSignIn }, { env, say }))
74
+ } catch (error) {
75
+ // One app's config that cannot be written (a permission, a file where a folder should be) is that app's failure,
76
+ // not the end of the run: the other apps are still set up and the report still says what is left.
77
+ const detail = error instanceof Error ? error.message : String(error)
78
+ results.push({ id: client.id, label: client.label, state: 'failed', next: `${detail} Add Renor by hand: ${client.byHand}` })
79
+ }
80
+ }
81
+
82
+ let skill = null
83
+ if (options.skill) {
84
+ if (!which('npx', env)) {
85
+ skill = { ok: false, detail: 'npx was not found.' }
86
+ } else {
87
+ say(paint.dim('Installing the Renor skill, which tells your AI when to save and when to recall…'))
88
+ const status = interactive('npx', SKILL_INSTALL_ARGS, { env })
89
+ skill = status === 0 ? { ok: true } : { ok: false, detail: `The skill install exited with ${status}.` }
90
+ }
91
+ }
92
+
93
+ await verifyAll(found, results, { env, say, paint, io, options })
94
+
95
+ report(results, skill, { say, paint })
96
+ const sharing = await askAboutSharing(io, options)
97
+ const sharingLine = outcomeLine(sharing, env)
98
+ if (sharingLine) say(sharingLine)
99
+ const failures = results.filter((result) => result.state === 'failed' || result.verified === false).length + (skill && !skill.ok ? 1 : 0)
100
+ return { code: failures > 0 ? 1 : 0, results, skill }
101
+ }
102
+
103
+ /** The proof: the gateway answers; with --verify, Claude Code (the app that can be limited to Renor's tools) saves and recalls one test memory. */
104
+ async function verifyAll(found, results, { env, say, paint, io, options }) {
105
+ say()
106
+ const gateway = await probeGateway(io.fetch)
107
+ if (gateway.ok) say(`${paint.ok('✓')} Renor’s gateway answers.`)
108
+ else {
109
+ say(`${paint.warn('!')} ${gateway.detail}`)
110
+ say(` Fix: ${gateway.fix}`)
111
+ }
112
+ if (!options.verify) return gateway.ok
113
+
114
+ const hasCheck = (result) => Boolean(found.find(({ client }) => client.id === result.id).client.check)
115
+ for (const result of results) {
116
+ if ((result.state === 'already' || result.state === 'added') && !hasCheck(result)) result.unchecked = true
117
+ }
118
+ const checkable = results.filter((result) => (result.state === 'already' || (result.state === 'added' && result.signedIn)) && hasCheck(result))
119
+ if (checkable.length === 0) {
120
+ say(paint.dim('Nothing to check from here yet: Claude Code is the app setup can test, and it is not signed in to Renor. Sign in as listed below, then run npx renor setup --verify.'))
121
+ return gateway.ok
122
+ }
123
+ if (!gateway.ok) {
124
+ for (const result of checkable) result.verified = false
125
+ return false
126
+ }
127
+ say(paint.dim('Checking Claude Code: it saves one test memory to Renor and recalls it. This can take a minute and uses a little of its model quota.'))
128
+ for (const result of checkable) {
129
+ const { client } = found.find((item) => item.client.id === result.id)
130
+ const code = checkCode()
131
+ result.checkCode = code
132
+ const check = verifyClient(client, { env, code, run: io.run })
133
+ result.verified = check.ok
134
+ let retiredId = check.retiredId
135
+ result.noteRetired = check.retired
136
+ if (!check.retired) {
137
+ const cleanup = cleanupClient(client, { env, code, savedId: check.savedId, run: io.run })
138
+ result.noteRetired = cleanup.retired
139
+ retiredId = cleanup.retiredId ?? retiredId
140
+ }
141
+ if (check.wrongRetire) {
142
+ check.ok = false
143
+ check.detail = `${client.label} retired a different memory (id ${check.wrongRetire.retired}) than the one it saved (id ${check.wrongRetire.saved}).`
144
+ check.fix = `put it back with restore_memory and the id ${check.wrongRetire.retired} (ask any connected AI, or use the dashboard's memory list). Setup then retired the test note itself.`
145
+ } else if (result.noteRetired && check.recalledOk) {
146
+ // The recall worked and the cleanup pass confirmed the retire the app had not.
147
+ check.ok = true
148
+ } else if (!result.noteRetired) {
149
+ check.ok = false
150
+ check.detail = `${check.detail ?? 'The check finished'}; setup could not retire the test note Renor setup check ${code}.`
151
+ check.fix = `Run npx renor setup --verify again; if it persists, write to hello@renor.cloud with the code ${code}.`
152
+ }
153
+ result.noteId = retiredId
154
+ result.verified = check.ok
155
+ if (!check.ok) {
156
+ result.verifyDetail = check.detail
157
+ result.verifyFix = check.fix
158
+ }
159
+ }
160
+ if (checkable.every((result) => result.noteRetired)) say(`${paint.ok('✓')} The test note${checkable.length === 1 && checkable[0].noteId ? ` (id ${checkable[0].noteId})` : ''} was retired again: it no longer comes back in search.`)
161
+ return gateway.ok
162
+ }
163
+
164
+ function planFor(client, detection, now, options) {
165
+ if (detection.blocked) return detection.blocked
166
+ if (now.configured && now.url === GATEWAY_URL && !options.force) return 'already has Renor; nothing to do.'
167
+ if (now.configured && !options.force) return `has a renor server at ${now.url ?? 'another address'}; it would be left alone (use --force to replace it).`
168
+ if (now.configured) return 'its renor server would be replaced with Renor’s address.'
169
+ return client.login ? 'Renor would be added, then signed in.' : 'Renor would be added.'
170
+ }
171
+
172
+ async function setUpOne(client, detection, options, { env, say }) {
173
+ const base = { id: client.id, label: client.label }
174
+ if (detection.blocked) return { ...base, state: 'blocked', next: detection.blocked }
175
+
176
+ const now = client.current({ env })
177
+ if (!now.readable) {
178
+ return { ...base, state: 'failed', next: `Its config file could not be read. Add Renor by hand: ${client.byHand}` }
179
+ }
180
+ if (now.configured && now.url === GATEWAY_URL && !options.force) {
181
+ return { ...base, state: 'already', next: null }
182
+ }
183
+ if (now.configured && !options.force) {
184
+ return {
185
+ ...base,
186
+ state: 'different',
187
+ next: `It already has a renor server at ${now.url ?? 'another address'}, left as it is. Run renor setup --force to replace it.`,
188
+ }
189
+ }
190
+
191
+ const ctx = { env, canSignIn: options.canSignIn, say }
192
+ const outcome = client.add(ctx, { replace: now.configured })
193
+ if (!outcome.ok) {
194
+ return { ...base, state: 'failed', next: `${outcome.detail ? outcome.detail + ' ' : ''}Add Renor by hand: ${client.byHand}` }
195
+ }
196
+
197
+ let signedIn = outcome.signedIn
198
+ const login = !signedIn && options.canSignIn && client.login ? (typeof client.login === 'function' ? client.login(ctx) : client.login) : null
199
+ if (login) {
200
+ say(`Signing in to Renor for ${client.label}. Your browser opens: sign in and approve.`)
201
+ signedIn = interactive(login[0], login[1], { env }) === 0
202
+ }
203
+ return {
204
+ ...base,
205
+ state: 'added',
206
+ signedIn,
207
+ detail: outcome.detail,
208
+ next: signedIn ? null : client.signIn,
209
+ }
210
+ }
211
+
212
+ function report(results, skill, { say, paint }) {
213
+ say()
214
+ say(paint.bold('Done.'))
215
+ const wired = results.filter((result) => result.state === 'already' || result.state === 'added').length
216
+ const verified = results.filter((result) => result.verified === true).length
217
+ say(`${wired} of ${results.length} wired${results.some((result) => result.verified !== undefined) ? `, ${verified} verified` : ''}.`)
218
+ const width = Math.max(...results.map((result) => result.label.length))
219
+ for (const result of results) {
220
+ const name = result.label.padEnd(width)
221
+ if (result.state === 'already') say(` ${paint.ok('✓')} ${name} already had Renor`)
222
+ else if (result.state === 'added' && result.signedIn) say(` ${paint.ok('✓')} ${name} added and signed in`)
223
+ else if (result.state === 'added') say(` ${paint.todo('•')} ${name} added. Left for you: ${result.next}`)
224
+ else say(` ${paint.warn('!')} ${name} ${result.next}`)
225
+ if (result.detail) say(` ${paint.dim(result.detail)}`)
226
+ if (result.verified === true) say(` ${paint.ok('✓')} verified: saved a test memory and recalled it`)
227
+ if (result.unchecked) say(` ${paint.dim(`not checked by setup: tell it "${FIRST_MEMORY_SAVE}", then ask "${FIRST_MEMORY_RECALL}" in a new chat.`)}`)
228
+ if (result.verified === false) {
229
+ say(` ${paint.warn('!')} not verified: ${result.verifyDetail ?? 'Renor’s gateway could not be reached.'}`)
230
+ if (result.verifyFix) say(` Fix: ${result.verifyFix}`)
231
+ }
232
+ }
233
+ if (skill) {
234
+ say(skill.ok ? ` ${paint.ok('✓')} ${'Skill'.padEnd(width)} installed` : ` ${paint.warn('!')} ${'Skill'.padEnd(width)} not installed: ${skill.detail}`)
235
+ }
236
+ say()
237
+ say(`Try it: tell one of them "${FIRST_MEMORY_SAVE}"`)
238
+ say(`Then, in a new chat or another app, ask "${FIRST_MEMORY_RECALL}"`)
239
+ say(paint.dim('ChatGPT and Claude in the browser: open https://app.renor.cloud for their steps.'))
240
+ }
241
+
242
+ function painter(out, env) {
243
+ const on = Boolean(out.isTTY) && !env.NO_COLOR
244
+ const wrap = (open, close) => (text) => (on ? `\u001b[${open}m${text}\u001b[${close}m` : text)
245
+ return { bold: wrap(1, 22), dim: wrap(2, 22), ok: wrap(32, 39), todo: wrap(36, 39), warn: wrap(33, 39) }
246
+ }
247
+
248
+ function defaultIo() {
249
+ return {
250
+ env: process.env,
251
+ out: process.stdout,
252
+ input: process.stdin,
253
+ output: process.stdout,
254
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
255
+ }
256
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Whether `renor setup` may ask the sharing question (#778): only while the operator's gate is open. The switch lives on the
3
+ * server (app_settings 'learning_sharing', supabase/migrations/20261024120000_learning_sharing_gate.sql) and the gateway reports
4
+ * it, as one public bit, at GET /sharing/status ({"open": true|false}). Anything but an answer of exactly {"open": true} (no
5
+ * network, a timeout, an older gateway without the route, a malformed body) means closed: the question is not asked.
6
+ *
7
+ * Opening the switch only lets setup ASK. Whether a row is ever stored is decided again by the server from the authenticated
8
+ * identity and that user's recorded consent; client-supplied workspace ids never decide it.
9
+ */
10
+ import { GATEWAY_URL } from './constants.mjs'
11
+
12
+ export const SHARING_STATUS_URL = new URL('/sharing/status', GATEWAY_URL).href
13
+
14
+ /** True only when the gateway answers {"open": true} within `timeoutMs`. */
15
+ export async function sharingPromptOpen({ fetchImpl = globalThis.fetch, url = SHARING_STATUS_URL, timeoutMs = 3000 } = {}) {
16
+ if (typeof fetchImpl !== 'function') return false
17
+ const controller = new AbortController()
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
19
+ try {
20
+ const response = await fetchImpl(url, {
21
+ headers: { accept: 'application/json' },
22
+ redirect: 'error',
23
+ signal: controller.signal,
24
+ })
25
+ if (!response.ok) return false
26
+ const body = await response.json()
27
+ return body !== null && typeof body === 'object' && body.open === true
28
+ } catch {
29
+ return false
30
+ } finally {
31
+ clearTimeout(timer)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Runs `ask` (the question #778 asks) only while the gate is open; otherwise returns 'closed' without asking or writing anything.
37
+ * #778's askAboutSharing calls it as its first step after its own not-interactive / environment / answered checks.
38
+ */
39
+ export async function whenSharingOpen(ask, options = {}) {
40
+ if (!(await sharingPromptOpen(options))) return 'closed'
41
+ return ask()
42
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The one question `renor setup` asks, once: do you want to share anonymous usage signals with Renor? The answer is No unless the
3
+ * person says yes, and nothing is sent by this command either way: it only writes the choice, in this install's sharing folder,
4
+ * which the open-source engine (engine/src/sharing/state.ts) reads as a limit on every workspace folder on this install: the
5
+ * stricter of the install's answer and a workspace's own record is in force, and an engine finding the install at No turns any
6
+ * workspace record that is still on to off (engine/src/sharing/loop.ts). A No therefore stops every engine on this install that
7
+ * reads this folder; a yes never turns a workspace on by itself.
8
+ *
9
+ * The question is asked only while the operator's gate is open (cli/lib/sharing-gate.mjs, #961): closed, it asks and writes nothing.
10
+ *
11
+ * A hosted workspace has its own switch (the dashboard's Settings), which an owner signed in there controls; this file does not
12
+ * touch it. The record has the level, the time, the id of the words the person saw and their confirmation of being an adult, as the
13
+ * engine writes them (a test in tests/ holds the two in step).
14
+ */
15
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join, resolve } from 'node:path'
18
+ import { createInterface } from 'node:readline'
19
+ import { whenSharingOpen } from './sharing-gate.mjs'
20
+
21
+ /** The id of the words below. Change the words, change the id. */
22
+ export const SETUP_WORDING_VERSION = 'setup-2026-10-21'
23
+
24
+ export const SHARING_QUESTION = `One question, asked once: help Renor learn from how its memory is used?
25
+ Share anonymous usage signals: numbers only (which memory was returned, its position and score, and whether your AI then opened or
26
+ cited it, times to the hour). Never memory text, queries, names or paths. Off is the default; you can change it any time with
27
+ \`renor-engine telemetry\`, and every field is listed in the engine's docs/TELEMETRY.md. Saying yes also confirms you are 18 or older.
28
+ This command sends nothing itself.
29
+ Share anonymous usage signals? [y/N] `
30
+
31
+ export function sharingDir(env = process.env) {
32
+ const set = (env.RENOR_SHARING_DIR || '').trim()
33
+ if (set !== '') return resolve(set)
34
+ const xdg = (env.XDG_CONFIG_HOME || '').trim()
35
+ return join(xdg !== '' ? resolve(xdg) : join(homedir(), '.config'), 'renor')
36
+ }
37
+
38
+ /** True when a person (or this command) already recorded an answer: the question is never asked twice. */
39
+ export function hasAnswer(env = process.env) {
40
+ try {
41
+ const record = JSON.parse(readFileSync(join(sharingDir(env), 'sharing.json'), 'utf8'))
42
+ return record && record.version === 1 && ['off', 'signals', 'data'].includes(record.level)
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ /** An environment that says no already answered: DO_NOT_TRACK=1 or RENOR_SHARING=off. */
49
+ export function environmentSaysNo(env = process.env) {
50
+ return (env.DO_NOT_TRACK || '').trim() === '1' || (env.RENOR_SHARING || '').trim().toLowerCase() === 'off'
51
+ }
52
+
53
+ export function recordAnswer(env, level, now = new Date()) {
54
+ const dir = sharingDir(env)
55
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
56
+ // mkdirSync's mode applies only to a folder it creates: one that already exists (made by hand, or by an older tool) is
57
+ // tightened here, so the folder holding the consent record is the owner's alone on every write.
58
+ chmodSync(dir, 0o700)
59
+ const record = {
60
+ version: 1,
61
+ level,
62
+ changedAt: now.toISOString(),
63
+ wording: SETUP_WORDING_VERSION,
64
+ adult: level !== 'off',
65
+ source: 'setup',
66
+ }
67
+ const temp = join(dir, `.sharing.json.${process.pid}.tmp`)
68
+ writeFileSync(temp, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 })
69
+ renameSync(temp, join(dir, 'sharing.json'))
70
+ return record
71
+ }
72
+
73
+ function ask(question, { input = process.stdin, output = process.stdout } = {}) {
74
+ return new Promise((resolveAnswer) => {
75
+ const rl = createInterface({ input, output })
76
+ rl.question(question, (answer) => {
77
+ rl.close()
78
+ resolveAnswer(/^\s*y(es)?\s*$/i.test(answer))
79
+ })
80
+ })
81
+ }
82
+
83
+ /**
84
+ * What setup may say once the answer is recorded: only what this command controls, which is one folder for one account. It
85
+ * cannot see a hosted workspace's choice (Settings > Learning) or an engine run under another account or with its own
86
+ * RENOR_SHARING_DIR, so it never claims that nothing is sent, and a yes never claims that sharing is on.
87
+ */
88
+ export function outcomeLine(result, env = process.env) {
89
+ const where = `Recorded for this computer's account, in ${join(sharingDir(env), 'sharing.json')}`
90
+ const elsewhere = 'A hosted workspace keeps its own switch in Settings on https://app.renor.cloud, and an engine run under another account reads its own folder.'
91
+ if (result === 'asked-no') {
92
+ return `${where}: not sharing. An engine that reads this folder stops sharing at its next check. ${elsewhere}`
93
+ }
94
+ if (result === 'asked-yes') {
95
+ return `${where}: sharing allowed up to anonymous usage signals. This turns nothing on by itself: an engine shares only for a workspace whose own choice is on (renor-engine telemetry). Undo it with renor-engine telemetry off. ${elsewhere}`
96
+ }
97
+ return null
98
+ }
99
+
100
+ /**
101
+ * Asks, in front of a person only, and records the answer. Returns what happened: 'asked-yes', 'asked-no', or the reason it did not
102
+ * ask ('not-interactive', 'answered', 'environment', or 'closed' while the operator's gate is closed).
103
+ */
104
+ export async function askAboutSharing(io, { yes, dryRun }) {
105
+ const env = io.env
106
+ if (yes || dryRun || !io.isTTY) return 'not-interactive'
107
+ if (environmentSaysNo(env)) return 'environment'
108
+ if (hasAnswer(env)) return 'answered'
109
+ return whenSharingOpen(async () => {
110
+ const wants = await ask(SHARING_QUESTION, io)
111
+ recordAnswer(env, wants ? 'signals' : 'off')
112
+ return wants ? 'asked-yes' : 'asked-no'
113
+ }, { fetchImpl: io.fetch })
114
+ }
package/lib/sync.mjs ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `renor sync`: keep the memory files you choose (CLAUDE.md, AGENTS.md, an agent's memory folder) in your Renor
3
+ * workspace, so Renor and the notes your agents already trust are one memory.
4
+ *
5
+ * renor sync add <file-or-folder>… choose what to sync (nothing is read or sent until you do)
6
+ * renor sync remove <path>… stop syncing it (what Renor already holds stays; forget it in the dashboard)
7
+ * renor sync [--dry-run] send what changed since the last run
8
+ *
9
+ * Each file is one import batch under Imported/synced/<path>, first line marking the source path. A changed file
10
+ * undoes its previous batch and imports the new text, so a note is never held twice. Only chosen paths are read,
11
+ * only .md files, and the token comes from RENOR_TOKEN (a connect token from the dashboard); nothing is stored.
12
+ */
13
+ import { createHash } from 'node:crypto'
14
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join, resolve, relative, sep } from 'node:path'
16
+ import { GATEWAY_URL } from './constants.mjs'
17
+ import { openSession } from './mcp.mjs'
18
+ import { home } from './system.mjs'
19
+
20
+ const LABEL = 'synced'
21
+ const FILE_BYTES = 64 * 1024
22
+ const EMPTY = { chosen: [], synced: {} }
23
+
24
+ export const statePath = (env) => join(home(env), '.renor', 'sync.json')
25
+
26
+ export function loadState(env) {
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(statePath(env), 'utf8'))
29
+ return { chosen: parsed.chosen ?? [], synced: parsed.synced ?? {} }
30
+ } catch {
31
+ return { ...EMPTY, chosen: [], synced: {} }
32
+ }
33
+ }
34
+
35
+ function saveState(env, state) {
36
+ const path = statePath(env)
37
+ mkdirSync(dirname(path), { recursive: true })
38
+ writeFileSync(path + '.tmp', JSON.stringify(state, null, 2))
39
+ renameSync(path + '.tmp', path)
40
+ }
41
+
42
+ /**
43
+ * Every .md file under the chosen paths. A path you chose may itself be a link (dotfiles); inside a chosen folder
44
+ * links are skipped, so a folder cannot lead the walk to files you did not choose. Dot-names and node_modules are skipped.
45
+ */
46
+ export function expand(chosen) {
47
+ const files = []
48
+ const walk = (path, top) => {
49
+ let info
50
+ try { info = top ? statSync(path) : lstatSync(path) } catch { return }
51
+ if (info.isDirectory()) {
52
+ for (const name of readdirSync(path).sort()) if (!name.startsWith('.') && name !== 'node_modules') walk(join(path, name), false)
53
+ } else if (info.isFile() && path.endsWith('.md')) files.push(path)
54
+ }
55
+ for (const path of chosen) walk(path, true)
56
+ return [...new Set(files)]
57
+ }
58
+
59
+ /** The shown path (~ for home) and the destination path inside Imported/synced/. A leading dot becomes _ (the engine skips dot-names), so .claude and claude stay apart. */
60
+ export function describe(path, env) {
61
+ const base = home(env)
62
+ const rel = relative(base, path)
63
+ const inHome = !rel.startsWith('..') && rel !== ''
64
+ const shown = inHome ? '~/' + rel.split(sep).join('/') : path
65
+ const parts = (inHome ? rel.split(sep) : ['_', ...path.split(sep).filter(Boolean)]).map((p) => p.replace(/^\./, '_')).filter(Boolean)
66
+ return { shown, relativePath: parts.join('/') }
67
+ }
68
+
69
+ const sha = (text) => createHash('sha256').update(text).digest('hex')
70
+
71
+ /** What to do for each chosen file, without calling Renor. */
72
+ export function plan(env, state = loadState(env)) {
73
+ const items = []
74
+ for (const path of expand(state.chosen)) {
75
+ const { shown, relativePath } = describe(path, env)
76
+ const markdown = `<!-- renor-sync source: ${shown} -->\n\n${readFileSync(path, 'utf8')}`
77
+ const previous = state.synced[path]
78
+ const hash = sha(markdown)
79
+ let action = previous?.hash === hash ? 'unchanged' : previous ? 'update' : 'new'
80
+ if (Buffer.byteLength(markdown) > FILE_BYTES) action = 'too-big'
81
+ items.push({ path, shown, relativePath, markdown, hash, action, previous })
82
+ }
83
+ return items
84
+ }
85
+
86
+ /** The folders (synced, synced-2, …) this file is not in now, holds no kept copy in, and has no copy waiting to be retired in, in order. */
87
+ function* freeLabels(previous) {
88
+ const waiting = [...(previous?.kept ?? []), ...(previous?.retire ?? [])].map((copy) => copy.label)
89
+ const used = new Set([previous?.label ?? (previous?.batchId ? LABEL : null), ...waiting])
90
+ for (let n = 1; n <= 50; n++) {
91
+ const label = n === 1 ? LABEL : `${LABEL}-${n}`
92
+ if (!used.has(label)) yield label
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Retire an earlier batch of this file. Renor keeps a note the owner edited there and says so (`preserved`): that copy
98
+ * stays, is listed as kept, and its folder is not used for this file again. Returns 'retired' or 'kept'; a call that throws stays queued.
99
+ */
100
+ async function retireOld(session, state, entry, old, shown, say, env) {
101
+ let undone
102
+ try {
103
+ undone = await session.call('undo_import', { batchId: old.batchId })
104
+ } catch (error) {
105
+ // A batch whose apply never happened has nothing to retire.
106
+ if (!/No applied batch|batch_not_found/.test(error instanceof Error ? error.message : '')) throw error
107
+ undone = {}
108
+ }
109
+ entry.retire = entry.retire.filter((copy) => copy.batchId !== old.batchId)
110
+ if (entry.retire.length === 0) delete entry.retire
111
+ // Preserved because another batch of this file still asserts the same text is not an edit; the last undo retires it.
112
+ const reasons = undone.preservedReasons ?? {}
113
+ const edited = (undone.preserved ?? []).filter((path) => !String(reasons[path] ?? '').startsWith('kept: re-asserted'))
114
+ let outcome = 'retired'
115
+ if (edited.length) {
116
+ if (!entry.kept?.some((copy) => copy.label === old.label)) entry.kept = [...(entry.kept ?? []), old]
117
+ say(` kept ${shown}: Renor kept the earlier copy (${reasons[edited[0]] || 'it was changed there, or could not be removed'}); the new copy is in`)
118
+ outcome = 'kept'
119
+ }
120
+ saveState(env, state)
121
+ return outcome
122
+ }
123
+
124
+ export async function sync(argv, io = { env: process.env, out: process.stdout, fetchImpl: fetch }) {
125
+ const { env, out } = io
126
+ const say = (line = '') => out.write(line + '\n')
127
+ const dryRun = argv.includes('--dry-run')
128
+ const words = argv.filter((arg) => !arg.startsWith('-'))
129
+ const [verb, ...rest] = words[0] === 'add' || words[0] === 'remove' || words[0] === 'list' ? words : ['run']
130
+ const state = loadState(env)
131
+
132
+ if (verb === 'add' || verb === 'remove') {
133
+ if (rest.length === 0) { say(`renor sync ${verb} needs at least one file or folder.`); return 2 }
134
+ const paths = rest.map((p) => resolve(p))
135
+ if (verb === 'add') {
136
+ const missing = paths.filter((p) => { try { statSync(p); return false } catch { return true } })
137
+ if (missing.length) { say(`Not found: ${missing.join(', ')}`); return 2 }
138
+ state.chosen = [...new Set([...state.chosen, ...paths])]
139
+ } else {
140
+ state.chosen = state.chosen.filter((p) => !paths.includes(p))
141
+ }
142
+ saveState(env, state)
143
+ const count = expand(state.chosen).length
144
+ say(`${verb === 'add' ? 'Chosen' : 'Left'}: ${paths.map((p) => describe(p, env).shown).join(', ')}. ${count} Markdown file${count === 1 ? '' : 's'} will sync. Run: renor sync`)
145
+ return 0
146
+ }
147
+
148
+ if (verb === 'list') {
149
+ for (const path of state.chosen) say(describe(path, env).shown)
150
+ if (state.chosen.length === 0) say('Nothing chosen. Add a file or folder: renor sync add ~/.claude/CLAUDE.md')
151
+ return 0
152
+ }
153
+
154
+ if (state.chosen.length === 0) {
155
+ say('Nothing chosen to sync. Pick the files you want Renor to hold, e.g.:')
156
+ say(' renor sync add ~/.claude/CLAUDE.md ./AGENTS.md')
157
+ return 0
158
+ }
159
+ const items = plan(env, state)
160
+ const pending = items.filter((item) => item.action === 'new' || item.action === 'update')
161
+ for (const item of items) say(` ${item.action.padEnd(9)} ${item.shown}${item.action === 'too-big' ? ' (over 64 KiB; split it)' : ''}`)
162
+ const here = new Set(items.map((item) => item.path))
163
+ for (const path of Object.keys(state.synced)) {
164
+ if (!here.has(path)) say(` gone ${describe(path, env).shown} (deleted or no longer chosen; Renor still holds it, forget it in the dashboard)`)
165
+ }
166
+ const leftovers = Object.values(state.synced).some((entry) => entry.retire?.length)
167
+ if (dryRun || (pending.length === 0 && !leftovers)) {
168
+ say(dryRun ? 'Dry run: nothing was sent.' : 'Everything is already in sync.')
169
+ return 0
170
+ }
171
+ const token = env.RENOR_TOKEN
172
+ if (!token) { say('Set RENOR_TOKEN to a connect token from the Renor dashboard, then run renor sync again.'); return 2 }
173
+
174
+ let session
175
+ try {
176
+ session = await openSession(env.RENOR_MCP_URL || GATEWAY_URL, token, io.fetchImpl)
177
+ } catch (error) {
178
+ say(error instanceof Error ? error.message : String(error))
179
+ return 2
180
+ }
181
+ let failed = 0
182
+ try {
183
+ // An old copy that could not be retired last time goes first.
184
+ for (const [path, entry] of Object.entries(state.synced)) {
185
+ for (const old of [...(entry.retire ?? [])]) {
186
+ const shown = describe(path, env).shown
187
+ try {
188
+ await retireOld(session, state, entry, old, shown, say, env)
189
+ } catch (error) {
190
+ failed++
191
+ say(` failed ${shown}: could not retire an earlier copy: ${error instanceof Error ? error.message : error}`)
192
+ }
193
+ }
194
+ }
195
+ for (const item of pending) {
196
+ try {
197
+ // The new copy goes to a folder the file does not use yet, so it can be written while the old one is still
198
+ // there; the old one is retired only after the new one exists, and a failure on the way leaves it as it was.
199
+ const files = [{ relativePath: item.relativePath, markdown: item.markdown }]
200
+ // A folder where a different note already sits at this path (an attempt whose reply was lost) would be a conflict: skip it.
201
+ let label
202
+ let check
203
+ for (const candidate of freeLabels(item.previous)) {
204
+ check = await session.call('preview_import', { label: candidate, files, stage: false })
205
+ if (check.secretGuard || check.skipped?.length || !check.conflicts?.length) { label = candidate; break }
206
+ }
207
+ if (!label) throw new Error('no free folder for this file in Renor')
208
+ if (check.secretGuard || check.skipped?.length) throw new Error('Renor would leave this file out (it looks like it holds a secret, or is empty); the earlier copy was kept')
209
+ const preview = await session.call('preview_import', { label, files })
210
+ // Written down before apply: if the reply to apply is lost the batch may still have been applied, and every
211
+ // batch this file has had must be retired later or its text stays in Renor beside the current one.
212
+ const tried = item.previous ?? (state.synced[item.path] = { lost: [] })
213
+ tried.lost = [...(tried.lost ?? []), { batchId: preview.batchId, label }]
214
+ saveState(env, state)
215
+ const applied = await session.call('apply_import', { batchId: preview.batchId })
216
+ if (applied.notesConflicting) throw new Error('a different note is already at that place in Renor')
217
+ // Identical text already there (an earlier run whose reply was lost) counts: the batch asserts it again.
218
+ if ((applied.notesWritten ?? 0) + (applied.notesUnchanged ?? 0) !== 1) throw new Error('Renor wrote nothing for this file')
219
+ const before = item.previous ?? tried
220
+ const retire = [
221
+ ...(before.retire ?? []),
222
+ ...(before.batchId ? [{ batchId: before.batchId, label: before.label ?? LABEL }] : []),
223
+ ...(before.lost ?? []).filter((copy) => copy.batchId !== applied.batchId),
224
+ ]
225
+ const entry = { hash: item.hash, batchId: applied.batchId, label, syncedAt: new Date().toISOString() }
226
+ if (retire.length) entry.retire = retire
227
+ if (before.kept?.length) entry.kept = before.kept
228
+ state.synced[item.path] = entry
229
+ saveState(env, state)
230
+ let kept = false
231
+ for (const old of retire) {
232
+ try {
233
+ kept = (await retireOld(session, state, entry, old, item.shown, say, env)) === 'kept' || kept
234
+ } catch (error) {
235
+ failed++
236
+ say(` failed ${item.shown}: the new copy is in, the earlier one could not be retired (it is retried next run): ${error instanceof Error ? error.message : error}`)
237
+ }
238
+ }
239
+ if (!kept) say(` synced ${item.shown}`)
240
+ } catch (error) {
241
+ failed++
242
+ say(` failed ${item.shown}: ${error instanceof Error ? error.message : error}`)
243
+ }
244
+ }
245
+ } finally {
246
+ await session.close()
247
+ }
248
+ return failed ? 1 : 0
249
+ }