renor 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/renor.mjs +57 -0
- package/lib/args.mjs +38 -0
- package/lib/clients.mjs +237 -0
- package/lib/constants.mjs +27 -0
- package/lib/setup.mjs +171 -0
- package/lib/system.mjs +194 -0
- package/package.json +35 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Renor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# renor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connects the AI apps on your computer to [Renor](https://renor.cloud), so they share one memory: tell one of them something, and the others know it too.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx renor setup
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It finds the apps you have, adds Renor to each, signs you in, and installs the Renor skill, which tells your AI when to save and when to recall. At the end it lists anything left for you to do, app by app.
|
|
10
|
+
|
|
11
|
+
| App | How Renor is added | Sign-in |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Claude Code | `claude mcp add --transport http --scope user renor …` | in Claude Code: `/mcp`, then Authenticate |
|
|
14
|
+
| Codex | `codex mcp add renor --url …` | opens your browser right away |
|
|
15
|
+
| Cursor | adds `renor` to `~/.cursor/mcp.json` (a backup of the old file is kept) | `cursor-agent mcp login renor`, or in Cursor's settings |
|
|
16
|
+
| OpenCode | `opencode mcp add renor --url … --global` | `opencode mcp auth renor` |
|
|
17
|
+
| Pi (0.99 or later) | `pi mcp add renor --url … --exposure direct` | `pi mcp login renor` |
|
|
18
|
+
| VS Code | `code --add-mcp …` | when Copilot first uses Renor |
|
|
19
|
+
|
|
20
|
+
Every app gets the same address, `https://mcp.renor.cloud/mcp`. No key is stored anywhere: you approve Renor in your browser, and each app keeps its own sign-in.
|
|
21
|
+
|
|
22
|
+
## What it does not do
|
|
23
|
+
|
|
24
|
+
- It never changes anything else in an app's config. A file with comments in it is left alone and you get the line to add by hand.
|
|
25
|
+
- If an app already has a server named `renor` that points somewhere else, it is left as it is unless you pass `--force`.
|
|
26
|
+
- It sends nothing anywhere itself. The only network use is the apps' own sign-ins and `npx skills` installing the skill.
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
--dry-run Say what would happen and change nothing
|
|
32
|
+
-y, --yes Do not ask before adding
|
|
33
|
+
--no-login Add Renor but leave the sign-ins for later
|
|
34
|
+
--no-skill Do not install the skill
|
|
35
|
+
--force Replace a renor server that points somewhere else
|
|
36
|
+
--only <apps> Only some apps: claude-code, codex, cursor, opencode, pi, vscode
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
ChatGPT and Claude in the browser are set up from your dashboard at https://app.renor.cloud.
|
|
40
|
+
|
|
41
|
+
Needs Node.js 18 or later.
|
package/bin/renor.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* renor — connects the AI apps on this computer to Renor, the memory they share.
|
|
4
|
+
* Usage: npx renor setup [--yes] [--dry-run] [--no-login] [--no-skill] [--force] [--only claude-code,codex]
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { HELP, parse } from '../lib/args.mjs'
|
|
8
|
+
import { CLIENTS } from '../lib/clients.mjs'
|
|
9
|
+
import { setup } from '../lib/setup.mjs'
|
|
10
|
+
|
|
11
|
+
function version() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
14
|
+
} catch {
|
|
15
|
+
return 'unknown'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const options = parse(process.argv.slice(2))
|
|
21
|
+
if (options.version) {
|
|
22
|
+
process.stdout.write(version() + '\n')
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
if (options.help || !options.command) {
|
|
26
|
+
process.stdout.write(HELP + '\n')
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
if (options.command !== 'setup') {
|
|
30
|
+
process.stderr.write(`Unknown command: ${options.command}\n\n${HELP}\n`)
|
|
31
|
+
return 2
|
|
32
|
+
}
|
|
33
|
+
if (options.unknown.length > 0) {
|
|
34
|
+
process.stderr.write(`Unknown option: ${options.unknown.join(' ')}\n\n${HELP}\n`)
|
|
35
|
+
return 2
|
|
36
|
+
}
|
|
37
|
+
const unknownApps = (options.only || []).filter((id) => !CLIENTS.some((client) => client.id === id))
|
|
38
|
+
if (unknownApps.length > 0) {
|
|
39
|
+
process.stderr.write(`Unknown app: ${unknownApps.join(', ')}. Known: ${CLIENTS.map((client) => client.id).join(', ')}\n`)
|
|
40
|
+
return 2
|
|
41
|
+
}
|
|
42
|
+
const { code } = await setup(options)
|
|
43
|
+
return code
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [major] = process.versions.node.split('.').map(Number)
|
|
47
|
+
if (major < 18) {
|
|
48
|
+
process.stderr.write(`renor needs Node.js 18 or later; this is ${process.versions.node}.\n`)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
main().then(
|
|
52
|
+
(code) => process.exit(code),
|
|
53
|
+
(error) => {
|
|
54
|
+
process.stderr.write(`renor setup failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
},
|
|
57
|
+
)
|
package/lib/args.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The command line of `renor`: what it accepts, and its help text. */
|
|
2
|
+
import { CLIENTS } from './clients.mjs'
|
|
3
|
+
|
|
4
|
+
export const HELP = `renor — connect your AI apps to Renor, the memory they share.
|
|
5
|
+
|
|
6
|
+
Usage
|
|
7
|
+
npx renor setup Find your AI apps, add Renor to each, sign in, install the skill
|
|
8
|
+
npx renor setup --dry-run Say what would happen and change nothing
|
|
9
|
+
|
|
10
|
+
Options
|
|
11
|
+
-y, --yes Do not ask before adding
|
|
12
|
+
--no-login Add Renor but leave the sign-ins for later
|
|
13
|
+
--no-skill Do not install the Renor skill
|
|
14
|
+
--force Replace a renor server that points somewhere else
|
|
15
|
+
--only <apps> Only these apps: ${CLIENTS.map((client) => client.id).join(', ')}
|
|
16
|
+
-h, --help Show this help
|
|
17
|
+
-v, --version Show the version
|
|
18
|
+
|
|
19
|
+
ChatGPT and Claude in the browser are set up from https://app.renor.cloud.`
|
|
20
|
+
|
|
21
|
+
export function parse(argv) {
|
|
22
|
+
const options = { command: null, yes: false, dryRun: false, login: true, skill: true, force: false, only: null, help: false, version: false, unknown: [] }
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const arg = argv[i]
|
|
25
|
+
if (arg === '-y' || arg === '--yes') options.yes = true
|
|
26
|
+
else if (arg === '--dry-run') options.dryRun = true
|
|
27
|
+
else if (arg === '--no-login') options.login = false
|
|
28
|
+
else if (arg === '--no-skill') options.skill = false
|
|
29
|
+
else if (arg === '--force') options.force = true
|
|
30
|
+
else if (arg === '--only') options.only = (argv[++i] || '').split(',').map((id) => id.trim()).filter(Boolean)
|
|
31
|
+
else if (arg.startsWith('--only=')) options.only = arg.slice(7).split(',').map((id) => id.trim()).filter(Boolean)
|
|
32
|
+
else if (arg === '-h' || arg === '--help') options.help = true
|
|
33
|
+
else if (arg === '-v' || arg === '--version') options.version = true
|
|
34
|
+
else if (!arg.startsWith('-') && !options.command) options.command = arg
|
|
35
|
+
else options.unknown.push(arg)
|
|
36
|
+
}
|
|
37
|
+
return options
|
|
38
|
+
}
|
package/lib/clients.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One adapter per AI app `renor setup` knows. Each says how to find the app, whether Renor is already in its config,
|
|
3
|
+
* how to add it (the app's own command where it has one, a careful file merge where it does not), and how to sign in.
|
|
4
|
+
*
|
|
5
|
+
* The commands are the ones the dashboard's setup prompt gives people (src/lib/landing-setup-prompt.ts). What each
|
|
6
|
+
* app does with them was checked against the real apps on 2026-10-19 in a throwaway home folder: Claude Code 2.1.303,
|
|
7
|
+
* Codex 0.163.0, OpenCode 2.0.32, Pi 1.3.0 and cursor-agent 2026.10.16.
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { GATEWAY_URL, SERVER_NAME } from './constants.mjs'
|
|
12
|
+
import { atLeast, backupFile, capture, home, interactive, readJsonc, stripJsonComments, stripTrailingCommas, versionOf, which, writeJsonWithBackup } from './system.mjs'
|
|
13
|
+
|
|
14
|
+
/** What an adapter's add reports. */
|
|
15
|
+
const added = (detail = null, signedIn = false) => ({ ok: true, detail, signedIn })
|
|
16
|
+
const failed = (detail) => ({ ok: false, detail, signedIn: false })
|
|
17
|
+
|
|
18
|
+
/** The URL a config entry points at, from the shapes the apps use (`url`, or `serverUrl` in older files). */
|
|
19
|
+
function urlOf(entry) {
|
|
20
|
+
if (!entry || typeof entry !== 'object') return null
|
|
21
|
+
return typeof entry.url === 'string' ? entry.url : typeof entry.serverUrl === 'string' ? entry.serverUrl : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A JSON file we may rewrite: it parsed, and it held no comments that a rewrite would drop. */
|
|
25
|
+
function plainJson(read) {
|
|
26
|
+
if (!read.ok) return false
|
|
27
|
+
if (!read.exists || !read.text) return true
|
|
28
|
+
return stripJsonComments(read.text).trim() === stripTrailingCommas(read.text).trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function vscodeUserDir(env) {
|
|
32
|
+
if (process.platform === 'darwin') return join(home(env), 'Library', 'Application Support', 'Code', 'User')
|
|
33
|
+
if (process.platform === 'win32') return join(env.APPDATA || join(home(env), 'AppData', 'Roaming'), 'Code', 'User')
|
|
34
|
+
return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), 'Code', 'User')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function opencodeDir(env) {
|
|
38
|
+
return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), 'opencode')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function codexConfig(env) {
|
|
42
|
+
return join(env.CODEX_HOME || join(home(env), '.codex'), 'config.toml')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Renor's table in Codex's config.toml, if any, and the url it holds. */
|
|
46
|
+
function codexEntry(text) {
|
|
47
|
+
const lines = text.split(/\r?\n/)
|
|
48
|
+
const start = lines.findIndex((line) => /^\s*\[\s*mcp_servers\s*\.\s*(?:renor|"renor"|'renor')\s*\]\s*(#.*)?$/.test(line))
|
|
49
|
+
if (start < 0) return null
|
|
50
|
+
for (let i = start + 1; i < lines.length && !/^\s*\[/.test(lines[i]); i++) {
|
|
51
|
+
const match = lines[i].match(/^\s*url\s*=\s*"([^"]*)"/)
|
|
52
|
+
if (match) return { url: match[1] }
|
|
53
|
+
}
|
|
54
|
+
return { url: null }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const CLIENTS = [
|
|
58
|
+
{
|
|
59
|
+
id: 'claude-code',
|
|
60
|
+
label: 'Claude Code',
|
|
61
|
+
detect: (ctx) => {
|
|
62
|
+
const bin = which('claude', ctx.env)
|
|
63
|
+
return bin ? { found: true, version: versionOf('claude', ctx.env) } : { found: false }
|
|
64
|
+
},
|
|
65
|
+
current: (ctx) => {
|
|
66
|
+
const read = readJsonc(join(home(ctx.env), '.claude.json'))
|
|
67
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
68
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
69
|
+
},
|
|
70
|
+
add: (ctx, { replace }) => {
|
|
71
|
+
if (replace) capture('claude', ['mcp', 'remove', SERVER_NAME, '-s', 'user'], { env: ctx.env })
|
|
72
|
+
const out = capture('claude', ['mcp', 'add', '--transport', 'http', '--scope', 'user', SERVER_NAME, GATEWAY_URL], { env: ctx.env })
|
|
73
|
+
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
74
|
+
},
|
|
75
|
+
login: null,
|
|
76
|
+
signIn: 'Restart Claude Code, type /mcp, choose renor, then Authenticate.',
|
|
77
|
+
byHand: `claude mcp add --transport http --scope user ${SERVER_NAME} ${GATEWAY_URL}`,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'codex',
|
|
81
|
+
label: 'Codex',
|
|
82
|
+
detect: (ctx) => (which('codex', ctx.env) ? { found: true, version: versionOf('codex', ctx.env) } : { found: false }),
|
|
83
|
+
current: (ctx) => {
|
|
84
|
+
const path = codexConfig(ctx.env)
|
|
85
|
+
if (!existsSync(path)) return { readable: true, configured: false, url: null }
|
|
86
|
+
try {
|
|
87
|
+
const entry = codexEntry(readFileSync(path, 'utf8'))
|
|
88
|
+
return { readable: true, configured: Boolean(entry), url: entry?.url ?? null }
|
|
89
|
+
} catch {
|
|
90
|
+
return { readable: false, configured: false, url: null }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
// `codex mcp add` writes the config, then starts the browser sign-in and waits for it. Run in front of the person
|
|
94
|
+
// that is the whole setup; where nobody can approve a sign-in, the table is written directly instead, so nothing waits.
|
|
95
|
+
add: (ctx, { replace }) => {
|
|
96
|
+
if (replace) capture('codex', ['mcp', 'remove', SERVER_NAME], { env: ctx.env })
|
|
97
|
+
if (ctx.canSignIn) {
|
|
98
|
+
ctx.say(`Adding Renor to Codex. Codex opens your browser: sign in to Renor and approve.`)
|
|
99
|
+
const status = interactive('codex', ['mcp', 'add', SERVER_NAME, '--url', GATEWAY_URL], { env: ctx.env })
|
|
100
|
+
const nowThere = existsSync(codexConfig(ctx.env)) && codexEntry(readFileSync(codexConfig(ctx.env), 'utf8'))
|
|
101
|
+
if (status === 0) return added(null, true)
|
|
102
|
+
return nowThere ? added('The sign-in did not finish.') : failed(`codex mcp add exited with ${status}`)
|
|
103
|
+
}
|
|
104
|
+
const path = codexConfig(ctx.env)
|
|
105
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
106
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
107
|
+
// A renor table that is still there (the remove above failed) would be written twice, which Codex cannot read.
|
|
108
|
+
if (codexEntry(before)) return failed(`${path} still has a [mcp_servers.${SERVER_NAME}] table that could not be removed, so it was left as it is.`)
|
|
109
|
+
const backup = before ? backupFile(path) : null
|
|
110
|
+
const table = `[mcp_servers.${SERVER_NAME}]\nurl = "${GATEWAY_URL}"\n`
|
|
111
|
+
appendFileSync(path, (before && !before.endsWith('\n') ? '\n' : '') + (before ? '\n' : '') + table)
|
|
112
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
113
|
+
},
|
|
114
|
+
login: null,
|
|
115
|
+
signIn: 'Run codex mcp login renor, then restart Codex.',
|
|
116
|
+
byHand: `codex mcp add ${SERVER_NAME} --url ${GATEWAY_URL}`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'cursor',
|
|
120
|
+
label: 'Cursor',
|
|
121
|
+
detect: (ctx) => {
|
|
122
|
+
const bin = which('cursor-agent', ctx.env)
|
|
123
|
+
if (bin) return { found: true, version: versionOf('cursor-agent', ctx.env) }
|
|
124
|
+
return existsSync(join(home(ctx.env), '.cursor')) ? { found: true, version: null } : { found: false }
|
|
125
|
+
},
|
|
126
|
+
current: (ctx) => {
|
|
127
|
+
const read = readJsonc(join(home(ctx.env), '.cursor', 'mcp.json'))
|
|
128
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
129
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
130
|
+
},
|
|
131
|
+
add: (ctx) => {
|
|
132
|
+
const path = join(home(ctx.env), '.cursor', 'mcp.json')
|
|
133
|
+
const read = readJsonc(path)
|
|
134
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
135
|
+
const value = read.value && typeof read.value === 'object' ? read.value : {}
|
|
136
|
+
value.mcpServers = { ...(value.mcpServers || {}), [SERVER_NAME]: { url: GATEWAY_URL } }
|
|
137
|
+
const backup = writeJsonWithBackup(path, value)
|
|
138
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
139
|
+
},
|
|
140
|
+
login: (ctx) => (which('cursor-agent', ctx.env) ? ['cursor-agent', ['mcp', 'login', SERVER_NAME]] : null),
|
|
141
|
+
signIn: 'Restart Cursor, open Settings › MCP, find renor and sign in.',
|
|
142
|
+
byHand: `Add { "mcpServers": { "${SERVER_NAME}": { "url": "${GATEWAY_URL}" } } } to ~/.cursor/mcp.json`,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'opencode',
|
|
146
|
+
label: 'OpenCode',
|
|
147
|
+
detect: (ctx) => {
|
|
148
|
+
if (which('opencode', ctx.env)) return { found: true, version: versionOf('opencode', ctx.env) }
|
|
149
|
+
return existsSync(opencodeDir(ctx.env)) ? { found: true, version: null } : { found: false }
|
|
150
|
+
},
|
|
151
|
+
current: (ctx) => {
|
|
152
|
+
for (const name of ['opencode.json', 'opencode.jsonc']) {
|
|
153
|
+
const read = readJsonc(join(opencodeDir(ctx.env), name))
|
|
154
|
+
if (!read.exists) continue
|
|
155
|
+
// OpenCode 1 keeps servers under `mcp`, OpenCode 2 under `mcp.servers`; it reads both.
|
|
156
|
+
const entry = read.value?.mcp?.servers?.[SERVER_NAME] ?? read.value?.mcp?.[SERVER_NAME]
|
|
157
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
158
|
+
}
|
|
159
|
+
return { readable: true, configured: false, url: null }
|
|
160
|
+
},
|
|
161
|
+
add: (ctx, { replace }) => {
|
|
162
|
+
// OpenCode's own command keeps the file's comments and format; an older OpenCode without it gets a careful merge.
|
|
163
|
+
if (!replace && which('opencode', ctx.env) && capture('opencode', ['mcp', 'add', '--help'], { env: ctx.env }).ok) {
|
|
164
|
+
const out = capture('opencode', ['mcp', 'add', SERVER_NAME, '--url', GATEWAY_URL, '--global'], { env: ctx.env })
|
|
165
|
+
if (out.ok) return added()
|
|
166
|
+
}
|
|
167
|
+
const path = join(opencodeDir(ctx.env), existsSync(join(opencodeDir(ctx.env), 'opencode.jsonc')) ? 'opencode.jsonc' : 'opencode.json')
|
|
168
|
+
const read = readJsonc(path)
|
|
169
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
170
|
+
const value = read.value && typeof read.value === 'object' ? read.value : {}
|
|
171
|
+
const mcp = { ...(value.mcp || {}) }
|
|
172
|
+
if (mcp.servers && typeof mcp.servers === 'object') mcp.servers = { ...mcp.servers, [SERVER_NAME]: { type: 'remote', url: GATEWAY_URL } }
|
|
173
|
+
else mcp[SERVER_NAME] = { type: 'remote', url: GATEWAY_URL }
|
|
174
|
+
value.mcp = mcp
|
|
175
|
+
const backup = writeJsonWithBackup(path, value)
|
|
176
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
177
|
+
},
|
|
178
|
+
login: (ctx) => (which('opencode', ctx.env) ? ['opencode', ['mcp', 'auth', SERVER_NAME]] : null),
|
|
179
|
+
signIn: 'Run opencode mcp auth renor, then restart OpenCode.',
|
|
180
|
+
byHand: `opencode mcp add ${SERVER_NAME} --url ${GATEWAY_URL} --global`,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'pi',
|
|
184
|
+
label: 'Pi',
|
|
185
|
+
detect: (ctx) => {
|
|
186
|
+
if (!which('pi', ctx.env)) return { found: false }
|
|
187
|
+
const version = versionOf('pi', ctx.env)
|
|
188
|
+
// `pi mcp` arrived in Pi 0.99.
|
|
189
|
+
return version && !atLeast(version, '0.99') ? { found: true, version, blocked: 'Update Pi first (pi update): Pi adds MCP servers from 0.99 on.' } : { found: true, version }
|
|
190
|
+
},
|
|
191
|
+
current: (ctx) => {
|
|
192
|
+
const read = readJsonc(join(home(ctx.env), '.pi', 'agent', 'mcp.json'))
|
|
193
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
194
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
195
|
+
},
|
|
196
|
+
// `pi mcp add` adds or replaces, so the same command serves a replacement.
|
|
197
|
+
add: (ctx) => {
|
|
198
|
+
const out = capture('pi', ['mcp', 'add', SERVER_NAME, '--url', GATEWAY_URL, '--exposure', 'direct'], { env: ctx.env })
|
|
199
|
+
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
200
|
+
},
|
|
201
|
+
login: () => ['pi', ['mcp', 'login', SERVER_NAME]],
|
|
202
|
+
signIn: 'Run pi mcp login renor, then /reload in Pi.',
|
|
203
|
+
byHand: `pi mcp add ${SERVER_NAME} --url ${GATEWAY_URL} --exposure direct`,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'vscode',
|
|
207
|
+
label: 'VS Code',
|
|
208
|
+
detect: (ctx) => (which('code', ctx.env) ? { found: true, version: versionOf('code', ctx.env) } : { found: false }),
|
|
209
|
+
current: (ctx) => {
|
|
210
|
+
const read = readJsonc(join(vscodeUserDir(ctx.env), 'mcp.json'))
|
|
211
|
+
const entry = read.value?.servers?.[SERVER_NAME]
|
|
212
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
213
|
+
},
|
|
214
|
+
add: (ctx, { replace }) => {
|
|
215
|
+
if (replace) {
|
|
216
|
+
const path = join(vscodeUserDir(ctx.env), 'mcp.json')
|
|
217
|
+
const read = readJsonc(path)
|
|
218
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
219
|
+
const value = read.value || {}
|
|
220
|
+
value.servers = { ...(value.servers || {}), [SERVER_NAME]: { type: 'http', url: GATEWAY_URL } }
|
|
221
|
+
const backup = writeJsonWithBackup(path, value)
|
|
222
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
223
|
+
}
|
|
224
|
+
const out = capture('code', ['--add-mcp', JSON.stringify({ name: SERVER_NAME, type: 'http', url: GATEWAY_URL })], { env: ctx.env })
|
|
225
|
+
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
226
|
+
},
|
|
227
|
+
login: null,
|
|
228
|
+
signIn: 'Open VS Code. Copilot asks you to sign in to Renor the first time it uses it.',
|
|
229
|
+
byHand: `code --add-mcp '${JSON.stringify({ name: SERVER_NAME, type: 'http', url: GATEWAY_URL })}'`,
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
export function findClient(id) {
|
|
234
|
+
return CLIENTS.find((client) => client.id === id) ?? null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { codexEntry, plainJson }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `renor setup` adds and installs. The same values the dashboard's setup prompt gives
|
|
3
|
+
* (src/lib/landing-setup-prompt.ts); tests/cli-setup.test.ts in the platform suite fails if they drift apart.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Renor's one public MCP address. Sign-in is OAuth in the browser, so nothing here carries a key. */
|
|
7
|
+
export const GATEWAY_URL = 'https://mcp.renor.cloud/mcp'
|
|
8
|
+
|
|
9
|
+
/** The server's name in every client's config. */
|
|
10
|
+
export const SERVER_NAME = 'renor'
|
|
11
|
+
|
|
12
|
+
/** The public skill, pinned: the exact `skills` CLI version and the renor-skills commit it fetches. */
|
|
13
|
+
export const SKILLS_CLI_VERSION = '1.7.2'
|
|
14
|
+
export const RENOR_SKILLS_COMMIT = '018fddc51e6066c30c6919fdf27bbb1c8b64187a'
|
|
15
|
+
export const SKILL_INSTALL_ARGS = [
|
|
16
|
+
'-y',
|
|
17
|
+
`skills@${SKILLS_CLI_VERSION}`,
|
|
18
|
+
'add',
|
|
19
|
+
`https://github.com/patrickpassosb/renor-skills/tree/${RENOR_SKILLS_COMMIT}`,
|
|
20
|
+
'--agent',
|
|
21
|
+
'*',
|
|
22
|
+
'-y',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/** What to tell the person to try once everything is connected (FIRST_MEMORY_CHECK in the platform). */
|
|
26
|
+
export const FIRST_MEMORY_SAVE = 'Save to Renor: my favorite editor is Neovim.'
|
|
27
|
+
export const FIRST_MEMORY_RECALL = 'What is my favorite editor?'
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
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 { confirm, interactive, which } from './system.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {boolean} options.yes add without asking first
|
|
12
|
+
* @param {boolean} options.dryRun say what would happen, change nothing
|
|
13
|
+
* @param {boolean} options.login run the sign-ins the apps offer from the terminal
|
|
14
|
+
* @param {boolean} options.skill install the Renor skill
|
|
15
|
+
* @param {boolean} options.force replace a renor entry that points somewhere else
|
|
16
|
+
* @param {string[]|null} options.only limit to these app ids
|
|
17
|
+
*/
|
|
18
|
+
export async function setup(options, io = defaultIo()) {
|
|
19
|
+
const { env, out, isTTY } = io
|
|
20
|
+
const paint = painter(out, env)
|
|
21
|
+
const say = (line = '') => out.write(line + '\n')
|
|
22
|
+
const canSignIn = options.login && isTTY
|
|
23
|
+
|
|
24
|
+
say(paint.bold('Renor setup'))
|
|
25
|
+
say(`Adds Renor (${GATEWAY_URL}) to the AI apps on this computer. Each app keeps everything else in its config.`)
|
|
26
|
+
say()
|
|
27
|
+
|
|
28
|
+
const pool = options.only ? CLIENTS.filter((client) => options.only.includes(client.id)) : CLIENTS
|
|
29
|
+
const found = []
|
|
30
|
+
const missing = []
|
|
31
|
+
for (const client of pool) {
|
|
32
|
+
const detection = client.detect({ env })
|
|
33
|
+
if (detection.found) found.push({ client, detection })
|
|
34
|
+
else missing.push(client)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (found.length === 0) {
|
|
38
|
+
say('No supported app was found: Claude Code, Codex, Cursor, OpenCode, Pi or VS Code.')
|
|
39
|
+
say('For ChatGPT or Claude in the browser, open https://app.renor.cloud and follow the steps there.')
|
|
40
|
+
return { code: 0, results: [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
say(`Found: ${found.map(({ client, detection }) => client.label + (detection.version ? ` ${detection.version}` : '')).join(', ')}.`)
|
|
44
|
+
if (missing.length > 0) say(paint.dim(`Not found: ${missing.map((client) => client.label).join(', ')}.`))
|
|
45
|
+
say()
|
|
46
|
+
|
|
47
|
+
if (options.dryRun) {
|
|
48
|
+
for (const { client, detection } of found) {
|
|
49
|
+
const now = client.current({ env })
|
|
50
|
+
say(` ${client.label}: ${planFor(client, detection, now, options)}`)
|
|
51
|
+
}
|
|
52
|
+
say()
|
|
53
|
+
say('Dry run: nothing was changed.')
|
|
54
|
+
return { code: 0, results: [] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!options.yes && isTTY) {
|
|
58
|
+
const signIns = canSignIn ? ' Your browser opens once for each app that signs in from here; approve Renor there.' : ''
|
|
59
|
+
const yes = await confirm(`Add Renor to ${found.length === 1 ? found[0].client.label : `these ${found.length} apps`}?${signIns} [Y/n] `, io)
|
|
60
|
+
if (!yes) {
|
|
61
|
+
say('Nothing was changed.')
|
|
62
|
+
return { code: 0, results: [] }
|
|
63
|
+
}
|
|
64
|
+
say()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const results = []
|
|
68
|
+
for (const { client, detection } of found) {
|
|
69
|
+
results.push(await setUpOne(client, detection, { ...options, canSignIn }, { env, say }))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let skill = null
|
|
73
|
+
if (options.skill) {
|
|
74
|
+
if (!which('npx', env)) {
|
|
75
|
+
skill = { ok: false, detail: 'npx was not found.' }
|
|
76
|
+
} else {
|
|
77
|
+
say(paint.dim('Installing the Renor skill, which tells your AI when to save and when to recall…'))
|
|
78
|
+
const status = interactive('npx', SKILL_INSTALL_ARGS, { env })
|
|
79
|
+
skill = status === 0 ? { ok: true } : { ok: false, detail: `The skill install exited with ${status}.` }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
report(results, skill, { say, paint })
|
|
84
|
+
const failures = results.filter((result) => result.state === 'failed').length + (skill && !skill.ok ? 1 : 0)
|
|
85
|
+
return { code: failures > 0 ? 1 : 0, results, skill }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function planFor(client, detection, now, options) {
|
|
89
|
+
if (detection.blocked) return detection.blocked
|
|
90
|
+
if (now.configured && now.url === GATEWAY_URL) return 'already has Renor; nothing to do.'
|
|
91
|
+
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).`
|
|
92
|
+
if (now.configured) return 'its renor server would be replaced with Renor’s address.'
|
|
93
|
+
return client.login ? 'Renor would be added, then signed in.' : 'Renor would be added.'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function setUpOne(client, detection, options, { env, say }) {
|
|
97
|
+
const base = { id: client.id, label: client.label }
|
|
98
|
+
if (detection.blocked) return { ...base, state: 'blocked', next: detection.blocked }
|
|
99
|
+
|
|
100
|
+
const now = client.current({ env })
|
|
101
|
+
if (!now.readable) {
|
|
102
|
+
return { ...base, state: 'failed', next: `Its config file could not be read. Add Renor by hand: ${client.byHand}` }
|
|
103
|
+
}
|
|
104
|
+
if (now.configured && now.url === GATEWAY_URL && !options.force) {
|
|
105
|
+
return { ...base, state: 'already', next: null }
|
|
106
|
+
}
|
|
107
|
+
if (now.configured && !options.force) {
|
|
108
|
+
return {
|
|
109
|
+
...base,
|
|
110
|
+
state: 'different',
|
|
111
|
+
next: `It already has a renor server at ${now.url ?? 'another address'}, left as it is. Run renor setup --force to replace it.`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ctx = { env, canSignIn: options.canSignIn, say }
|
|
116
|
+
const outcome = client.add(ctx, { replace: now.configured })
|
|
117
|
+
if (!outcome.ok) {
|
|
118
|
+
return { ...base, state: 'failed', next: `${outcome.detail ? outcome.detail + ' ' : ''}Add Renor by hand: ${client.byHand}` }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let signedIn = outcome.signedIn
|
|
122
|
+
const login = !signedIn && options.canSignIn && client.login ? (typeof client.login === 'function' ? client.login(ctx) : client.login) : null
|
|
123
|
+
if (login) {
|
|
124
|
+
say(`Signing in to Renor for ${client.label}. Your browser opens: sign in and approve.`)
|
|
125
|
+
signedIn = interactive(login[0], login[1], { env }) === 0
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
state: 'added',
|
|
130
|
+
signedIn,
|
|
131
|
+
detail: outcome.detail,
|
|
132
|
+
next: signedIn ? null : client.signIn,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function report(results, skill, { say, paint }) {
|
|
137
|
+
say()
|
|
138
|
+
say(paint.bold('Done.'))
|
|
139
|
+
const width = Math.max(...results.map((result) => result.label.length))
|
|
140
|
+
for (const result of results) {
|
|
141
|
+
const name = result.label.padEnd(width)
|
|
142
|
+
if (result.state === 'already') say(` ${paint.ok('✓')} ${name} already had Renor`)
|
|
143
|
+
else if (result.state === 'added' && result.signedIn) say(` ${paint.ok('✓')} ${name} added and signed in`)
|
|
144
|
+
else if (result.state === 'added') say(` ${paint.todo('•')} ${name} added. Left for you: ${result.next}`)
|
|
145
|
+
else say(` ${paint.warn('!')} ${name} ${result.next}`)
|
|
146
|
+
if (result.detail) say(` ${paint.dim(result.detail)}`)
|
|
147
|
+
}
|
|
148
|
+
if (skill) {
|
|
149
|
+
say(skill.ok ? ` ${paint.ok('✓')} ${'Skill'.padEnd(width)} installed` : ` ${paint.warn('!')} ${'Skill'.padEnd(width)} not installed: ${skill.detail}`)
|
|
150
|
+
}
|
|
151
|
+
say()
|
|
152
|
+
say(`Try it: tell one of them "${FIRST_MEMORY_SAVE}"`)
|
|
153
|
+
say(`Then, in a new chat or another app, ask "${FIRST_MEMORY_RECALL}"`)
|
|
154
|
+
say(paint.dim('ChatGPT and Claude in the browser: open https://app.renor.cloud for their steps.'))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function painter(out, env) {
|
|
158
|
+
const on = Boolean(out.isTTY) && !env.NO_COLOR
|
|
159
|
+
const wrap = (open, close) => (text) => (on ? `\u001b[${open}m${text}\u001b[${close}m` : text)
|
|
160
|
+
return { bold: wrap(1, 22), dim: wrap(2, 22), ok: wrap(32, 39), todo: wrap(36, 39), warn: wrap(33, 39) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function defaultIo() {
|
|
164
|
+
return {
|
|
165
|
+
env: process.env,
|
|
166
|
+
out: process.stdout,
|
|
167
|
+
input: process.stdin,
|
|
168
|
+
output: process.stdout,
|
|
169
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
170
|
+
}
|
|
171
|
+
}
|
package/lib/system.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few things `renor setup` asks of the machine: find a program, run it (quietly or in front of the person), and read
|
|
3
|
+
* or carefully write a JSON config file. Nothing here talks to the network itself.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
9
|
+
import { createInterface } from 'node:readline'
|
|
10
|
+
|
|
11
|
+
const WINDOWS = process.platform === 'win32'
|
|
12
|
+
|
|
13
|
+
/** The person's home folder. RENOR_SETUP_HOME exists for tests, so they never touch a real home. */
|
|
14
|
+
export function home(env = process.env) {
|
|
15
|
+
return env.RENOR_SETUP_HOME || env.HOME || env.USERPROFILE || homedir()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The full path of a program on PATH, or null. */
|
|
19
|
+
export function which(command, env = process.env) {
|
|
20
|
+
const dirs = (env.PATH || env.Path || '').split(delimiter).filter(Boolean)
|
|
21
|
+
const exts = WINDOWS ? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
|
|
22
|
+
for (const dir of dirs) {
|
|
23
|
+
for (const ext of exts) {
|
|
24
|
+
const candidate = join(dir, command + ext)
|
|
25
|
+
try {
|
|
26
|
+
if (statSync(candidate).isFile()) return candidate
|
|
27
|
+
} catch {
|
|
28
|
+
// not here
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Run a program and keep what it prints. Never throws; a program that could not start reports ok: false. */
|
|
36
|
+
export function capture(command, args, { env = process.env, timeoutMs = 20_000, input } = {}) {
|
|
37
|
+
const result = spawnSync(command, args, {
|
|
38
|
+
env,
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
timeout: timeoutMs,
|
|
41
|
+
input: input ?? '',
|
|
42
|
+
shell: WINDOWS,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
})
|
|
45
|
+
return {
|
|
46
|
+
ok: result.status === 0,
|
|
47
|
+
status: result.status,
|
|
48
|
+
stdout: result.stdout || '',
|
|
49
|
+
stderr: result.stderr || '',
|
|
50
|
+
error: result.error ? String(result.error.message || result.error) : null,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run a program in front of the person (a sign-in that opens the browser and waits). Returns its exit status. */
|
|
55
|
+
export function interactive(command, args, { env = process.env } = {}) {
|
|
56
|
+
const result = spawnSync(command, args, { env, stdio: 'inherit', shell: WINDOWS })
|
|
57
|
+
return result.status ?? 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A program's version line, or null. */
|
|
61
|
+
export function versionOf(command, env = process.env) {
|
|
62
|
+
const out = capture(command, ['--version'], { env, timeoutMs: 10_000 })
|
|
63
|
+
if (!out.ok) return null
|
|
64
|
+
const line = (out.stdout || out.stderr).trim().split('\n')[0] || ''
|
|
65
|
+
const match = line.match(/\d+\.\d+(\.\d+)?/)
|
|
66
|
+
return match ? match[0] : line || null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when `version` is at least `minimum` (both dotted numbers). */
|
|
70
|
+
export function atLeast(version, minimum) {
|
|
71
|
+
const a = String(version || '').split('.').map((part) => parseInt(part, 10) || 0)
|
|
72
|
+
const b = minimum.split('.').map((part) => parseInt(part, 10) || 0)
|
|
73
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
74
|
+
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0)
|
|
75
|
+
}
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* JSON with comments (OpenCode allows them), read without a dependency: comments outside strings are dropped and
|
|
81
|
+
* trailing commas removed. Returns { exists, ok, value } and never throws.
|
|
82
|
+
*/
|
|
83
|
+
export function readJsonc(path) {
|
|
84
|
+
if (!existsSync(path)) return { exists: false, ok: true, value: {} }
|
|
85
|
+
let text
|
|
86
|
+
try {
|
|
87
|
+
text = readFileSync(path, 'utf8')
|
|
88
|
+
} catch {
|
|
89
|
+
return { exists: true, ok: false, value: null }
|
|
90
|
+
}
|
|
91
|
+
if (!text.trim()) return { exists: true, ok: true, value: {}, text }
|
|
92
|
+
try {
|
|
93
|
+
return { exists: true, ok: true, value: JSON.parse(stripJsonComments(text)), text }
|
|
94
|
+
} catch {
|
|
95
|
+
return { exists: true, ok: false, value: null, text }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function stripJsonComments(text) {
|
|
100
|
+
let out = ''
|
|
101
|
+
let inString = false
|
|
102
|
+
for (let i = 0; i < text.length; i++) {
|
|
103
|
+
const ch = text[i]
|
|
104
|
+
const next = text[i + 1]
|
|
105
|
+
if (inString) {
|
|
106
|
+
out += ch
|
|
107
|
+
if (ch === '\\') {
|
|
108
|
+
out += next ?? ''
|
|
109
|
+
i++
|
|
110
|
+
} else if (ch === '"') {
|
|
111
|
+
inString = false
|
|
112
|
+
}
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (ch === '"') {
|
|
116
|
+
inString = true
|
|
117
|
+
out += ch
|
|
118
|
+
} else if (ch === '/' && next === '/') {
|
|
119
|
+
while (i < text.length && text[i] !== '\n') i++
|
|
120
|
+
out += '\n'
|
|
121
|
+
} else if (ch === '/' && next === '*') {
|
|
122
|
+
i += 2
|
|
123
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++
|
|
124
|
+
i++
|
|
125
|
+
} else {
|
|
126
|
+
out += ch
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return stripTrailingCommas(out)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function closesNext(text, from) {
|
|
133
|
+
let i = from
|
|
134
|
+
while (i < text.length && /\s/.test(text[i])) i++
|
|
135
|
+
return text[i] === '}' || text[i] === ']'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Drop a comma that sits right before a closing brace or bracket, outside strings (a `,}` inside a string stays). */
|
|
139
|
+
export function stripTrailingCommas(text) {
|
|
140
|
+
let out = ''
|
|
141
|
+
let inString = false
|
|
142
|
+
for (let i = 0; i < text.length; i++) {
|
|
143
|
+
const ch = text[i]
|
|
144
|
+
if (inString) {
|
|
145
|
+
out += ch
|
|
146
|
+
if (ch === '\\') {
|
|
147
|
+
out += text[i + 1] ?? ''
|
|
148
|
+
i++
|
|
149
|
+
} else if (ch === '"') {
|
|
150
|
+
inString = false
|
|
151
|
+
}
|
|
152
|
+
} else if (ch === '"') {
|
|
153
|
+
inString = true
|
|
154
|
+
out += ch
|
|
155
|
+
} else if (ch === ',' && closesNext(text, i + 1)) {
|
|
156
|
+
// a trailing comma: dropped
|
|
157
|
+
} else {
|
|
158
|
+
out += ch
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Write a JSON config. An existing file is copied next to itself first (`<name>.renor-backup-<time>`), and only a file
|
|
166
|
+
* that held plain JSON (no comments) is ever rewritten, so nothing the person wrote is lost.
|
|
167
|
+
*/
|
|
168
|
+
export function backupFile(path, now = new Date()) {
|
|
169
|
+
const backup = `${path}.renor-backup-${now.toISOString().replace(/[:.]/g, '-')}`
|
|
170
|
+
copyFileSync(path, backup)
|
|
171
|
+
return backup
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function writeJsonWithBackup(path, value, { now = new Date() } = {}) {
|
|
175
|
+
let backup = null
|
|
176
|
+
if (existsSync(path)) {
|
|
177
|
+
backup = backupFile(path, now)
|
|
178
|
+
} else {
|
|
179
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
180
|
+
}
|
|
181
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + '\n')
|
|
182
|
+
return backup
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Ask a yes/no question; Enter means yes. */
|
|
186
|
+
export function confirm(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const rl = createInterface({ input, output })
|
|
189
|
+
rl.question(question, (answer) => {
|
|
190
|
+
rl.close()
|
|
191
|
+
resolve(!/^\s*n/i.test(answer))
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "renor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect your AI apps (Claude Code, Codex, Cursor, OpenCode, Pi, VS Code) to Renor, the memory they share.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"renor": "bin/renor.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"renor",
|
|
23
|
+
"mcp",
|
|
24
|
+
"memory",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"codex",
|
|
27
|
+
"cursor",
|
|
28
|
+
"opencode"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://renor.cloud",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/patrickpassosb/renor.git",
|
|
34
|
+
"directory": "cli"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|