@sciilo.ai/codex-sidecar 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 +202 -0
- package/NOTICE +2 -0
- package/README.md +332 -0
- package/bin/sciilo-sidecar.js +221 -0
- package/package.json +53 -0
- package/src/banner.js +63 -0
- package/src/bridge.js +844 -0
- package/src/codex-app-server.js +125 -0
- package/src/codex-cli.js +37 -0
- package/src/config.js +87 -0
- package/src/document-seal.js +195 -0
- package/src/vault.js +493 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'node:readline/promises'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { stdin, stdout } from 'node:process'
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CODEX_MODEL,
|
|
8
|
+
defaultConfigPath,
|
|
9
|
+
loadConfig,
|
|
10
|
+
resolveWorkspace,
|
|
11
|
+
saveConfig,
|
|
12
|
+
} from '../src/config.js'
|
|
13
|
+
import { runCodexSync } from '../src/codex-cli.js'
|
|
14
|
+
import { SidecarBridge } from '../src/bridge.js'
|
|
15
|
+
import { banner } from '../src/banner.js'
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url)
|
|
18
|
+
|
|
19
|
+
const { version } = require('../package.json')
|
|
20
|
+
|
|
21
|
+
const [command = 'start', ...args] = process.argv.slice(2)
|
|
22
|
+
const configPath = valueAfter(args, '--config') || defaultConfigPath()
|
|
23
|
+
const chosenWorkspace = valueAfter(args, '--workspace')
|
|
24
|
+
const workspace = resolveWorkspace(chosenWorkspace)
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (command === 'setup') {
|
|
28
|
+
await setup(configPath)
|
|
29
|
+
} else if (command === 'status') {
|
|
30
|
+
await status(configPath)
|
|
31
|
+
} else if (command === 'start') {
|
|
32
|
+
let config = await loadConfig(configPath)
|
|
33
|
+
if (!config) config = await setup(configPath)
|
|
34
|
+
await start({ ...config, workspace }, configPath)
|
|
35
|
+
} else if (['help', '--help', '-h'].includes(command)) {
|
|
36
|
+
usage()
|
|
37
|
+
} else {
|
|
38
|
+
usage()
|
|
39
|
+
process.exitCode = 2
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`sidecar: ${error.message}`)
|
|
43
|
+
process.exitCode = 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function setup(path) {
|
|
47
|
+
const previous = await loadConfig(path)
|
|
48
|
+
const terminal = createInterface({ input: stdin, output: stdout })
|
|
49
|
+
try {
|
|
50
|
+
stdout.write(banner('Codex sidecar · setup', { version }))
|
|
51
|
+
const appUrl = await terminal.question(
|
|
52
|
+
`Application URL [${previous?.appUrl || 'https://sciilo.ai'}]: `,
|
|
53
|
+
) || previous?.appUrl || 'https://sciilo.ai'
|
|
54
|
+
const connectionKey = await terminal.question(
|
|
55
|
+
`Connection key${previous ? ' [leave empty to keep the current one]' : ''}: `,
|
|
56
|
+
) || previous?.connectionKey
|
|
57
|
+
const modelProvider = await terminal.question(
|
|
58
|
+
`Codex provider${previous?.modelProvider ? ` [${previous.modelProvider}]` : ' [Codex configuration]' }: `,
|
|
59
|
+
) || previous?.modelProvider || null
|
|
60
|
+
const defaultModel = previous?.model || DEFAULT_CODEX_MODEL
|
|
61
|
+
const model = await terminal.question(
|
|
62
|
+
`Model [${defaultModel}]: `,
|
|
63
|
+
) || defaultModel
|
|
64
|
+
const config = await saveConfig({
|
|
65
|
+
appUrl,
|
|
66
|
+
connectionKey,
|
|
67
|
+
codexCommand: previous?.codexCommand,
|
|
68
|
+
modelProvider,
|
|
69
|
+
model,
|
|
70
|
+
}, path)
|
|
71
|
+
console.log(`Configuration saved to ${path} (mode 0600).`)
|
|
72
|
+
configureCodexAuthentication(config.codexCommand)
|
|
73
|
+
return config
|
|
74
|
+
} finally {
|
|
75
|
+
terminal.close()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function configureCodexAuthentication(codexCommand) {
|
|
80
|
+
const statusResult = runCodexSync(codexCommand, ['login', 'status'], {
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
})
|
|
84
|
+
if (statusResult.status === 0) {
|
|
85
|
+
console.log(commandOutput(statusResult) || 'Codex is already signed in.')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const apiKey = process.env.SCIILO_CODEX_API_KEY
|
|
90
|
+
if (apiKey) {
|
|
91
|
+
const login = runCodexSync(codexCommand, ['login', '--with-api-key'], {
|
|
92
|
+
input: `${apiKey}\n`,
|
|
93
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
})
|
|
96
|
+
if (login.status !== 0) throw new Error('Signing in to Codex with an API key failed.')
|
|
97
|
+
console.log('API key handed to the Codex credential store; the sidecar keeps no copy.')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('No Codex session found. Opening the Codex sign-in…')
|
|
102
|
+
const login = runCodexSync(codexCommand, ['login'], { stdio: 'inherit' })
|
|
103
|
+
if (login.status !== 0) {
|
|
104
|
+
throw new Error('Codex sign-in did not complete.')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function status(path) {
|
|
109
|
+
const config = await loadConfig(path)
|
|
110
|
+
if (!config) {
|
|
111
|
+
console.log(`Sidecar is not configured yet (${path}).`)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const login = runCodexSync(config.codexCommand, ['login', 'status'], {
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
})
|
|
117
|
+
const loginStatus = login.status === 0
|
|
118
|
+
? commandOutput(login) || 'signed in'
|
|
119
|
+
: 'not signed in'
|
|
120
|
+
console.log(`Application : ${config.appUrl}`)
|
|
121
|
+
console.log(`Workspace : ${workspace}${chosenWorkspace ? '' : ' (current directory)'}`)
|
|
122
|
+
console.log(`Codex : ${loginStatus}`)
|
|
123
|
+
console.log(`Key : configured (${config.connectionKey.slice(0, 12)}…)`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function start(config, path) {
|
|
127
|
+
stdout.write(banner('Codex sidecar', { version }))
|
|
128
|
+
const bridge = new SidecarBridge(config)
|
|
129
|
+
bridge.on('connected', () => console.log(
|
|
130
|
+
`sidecar: connected to ${config.appUrl} — project ${config.workspace}`))
|
|
131
|
+
bridge.on('disconnected', ({ pairingRequired } = {}) => {
|
|
132
|
+
if (!pairingRequired) {
|
|
133
|
+
console.log('sidecar: connection lost, retrying…')
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
let pairingUpdate
|
|
137
|
+
bridge.on('pairingRequired', ({ reason } = {}) => {
|
|
138
|
+
if (pairingUpdate) return
|
|
139
|
+
pairingUpdate = renewConnectionKey(path, config, bridge, reason)
|
|
140
|
+
.then(updated => {
|
|
141
|
+
if (updated) config = { ...updated, workspace: config.workspace }
|
|
142
|
+
})
|
|
143
|
+
.catch(error => console.error(`sidecar: ${error.message}`))
|
|
144
|
+
.finally(() => {
|
|
145
|
+
pairingUpdate = null
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
bridge.on('log', line => {
|
|
149
|
+
if (line) console.error(`codex: ${line}`)
|
|
150
|
+
})
|
|
151
|
+
bridge.on('error', error => console.error(`sidecar: ${error.message}`))
|
|
152
|
+
const stop = () => {
|
|
153
|
+
bridge.stop()
|
|
154
|
+
process.exit(0)
|
|
155
|
+
}
|
|
156
|
+
process.once('SIGINT', stop)
|
|
157
|
+
process.once('SIGTERM', stop)
|
|
158
|
+
await bridge.start()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function renewConnectionKey(path, config, bridge, reason) {
|
|
162
|
+
console.log(`sidecar: ${revocationDetail(reason)}`)
|
|
163
|
+
console.log('sidecar: click Connect in Sciilo, copy the new key, then paste it here.')
|
|
164
|
+
|
|
165
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
166
|
+
console.log(`sidecar: not an interactive terminal; run "sciilo-sidecar setup --config ${path}" instead.`)
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const terminal = createInterface({ input: stdin, output: stdout })
|
|
171
|
+
try {
|
|
172
|
+
while (true) {
|
|
173
|
+
const connectionKey = (await terminal.question('New connection key: ')).trim()
|
|
174
|
+
if (!connectionKey) {
|
|
175
|
+
console.log('The key is required.')
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const updated = await saveConfig({ ...config, connectionKey }, path)
|
|
180
|
+
console.log('sidecar: new key saved, reconnecting…')
|
|
181
|
+
bridge.updateConnectionKey(updated.connectionKey)
|
|
182
|
+
return updated
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.log(`Key rejected: ${error.message}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
terminal.close()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function revocationDetail(reason) {
|
|
193
|
+
if (reason === 'replaced') return 'its connection key was replaced in Sciilo.'
|
|
194
|
+
if (reason === 'reconnect_flood') {
|
|
195
|
+
return 'its connection key was revoked: several sidecars fought over the connection. '
|
|
196
|
+
+ 'Stop the extra one before generating a new key.'
|
|
197
|
+
}
|
|
198
|
+
return 'its connection key was revoked in Sciilo.'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function valueAfter(args, flag) {
|
|
202
|
+
const index = args.indexOf(flag)
|
|
203
|
+
return index >= 0 ? args[index + 1] : null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function commandOutput(result) {
|
|
207
|
+
return result.stdout?.trim() || result.stderr?.trim() || ''
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function usage() {
|
|
211
|
+
console.log(`Usage:
|
|
212
|
+
sciilo-sidecar setup [--config path]
|
|
213
|
+
sciilo-sidecar start [--workspace path] [--config path]
|
|
214
|
+
sciilo-sidecar status [--workspace path] [--config path]
|
|
215
|
+
|
|
216
|
+
Without --workspace, the sidecar takes the current directory as the project.
|
|
217
|
+
|
|
218
|
+
Codex is installed automatically as a private sidecar dependency.
|
|
219
|
+
|
|
220
|
+
See README.md for ChatGPT sign-in, existing-session, and API-key paths.`)
|
|
221
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sciilo.ai/codex-sidecar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local bridge between Sciilo and Codex App Server",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"sciilo-sidecar": "./bin/sciilo-sidecar.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node ./bin/sciilo-sidecar.js start",
|
|
12
|
+
"setup": "node ./bin/sciilo-sidecar.js setup",
|
|
13
|
+
"status": "node ./bin/sciilo-sidecar.js status",
|
|
14
|
+
"test": "node --test test/*.test.js",
|
|
15
|
+
"vault:sync": "cp \"${SCIILO_VAULT:-../sidecar/vault/vault.js}\" src/vault.js",
|
|
16
|
+
"test:integration": "node ./scripts/quarkus-smoke.js",
|
|
17
|
+
"prepublishOnly": "npm test"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@openai/codex": "0.147.0",
|
|
24
|
+
"ws": "^8.18.3"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"NOTICE"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"sciilo",
|
|
35
|
+
"codex",
|
|
36
|
+
"openai",
|
|
37
|
+
"agent",
|
|
38
|
+
"sidecar",
|
|
39
|
+
"documentation",
|
|
40
|
+
"diagrams"
|
|
41
|
+
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/sciilo/sciilo-codex-sidecar.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/sciilo/sciilo-codex-sidecar#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/sciilo/sciilo-codex-sidecar/issues"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/banner.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The start-up banner: the Sciilo mark and wordmark, drawn for a terminal.
|
|
3
|
+
*
|
|
4
|
+
* <p>The mark is the one from the application — three connected nodes, one on
|
|
5
|
+
* the left reaching two on the right — so the command that runs on a developer's
|
|
6
|
+
* machine and the page in their browser are recognisably the same product.</p>
|
|
7
|
+
*
|
|
8
|
+
* <p>Two things are deliberately conditional. Colour is dropped unless the
|
|
9
|
+
* output is a terminal that wants it, because a banner is also written to log
|
|
10
|
+
* files and journals, where escape codes are noise. The drawing itself is
|
|
11
|
+
* dropped when the output is not a terminal at all: a piped `sciilo-sidecar`
|
|
12
|
+
* should print a line one can read and grep, not five lines of blocks.</p>
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SAGE = '\x1b[38;2;123;156;139m'
|
|
16
|
+
|
|
17
|
+
const BOLD = '\x1b[1m'
|
|
18
|
+
|
|
19
|
+
const RESET = '\x1b[0m'
|
|
20
|
+
|
|
21
|
+
// Three connected nodes: one on the left, two on the right.
|
|
22
|
+
const MARK = [
|
|
23
|
+
' ● ',
|
|
24
|
+
' ╱ ',
|
|
25
|
+
'● ',
|
|
26
|
+
' ╲ ',
|
|
27
|
+
' ● ',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const WORDMARK = [
|
|
31
|
+
'██████ ██████ ██ ██ ██ ██████',
|
|
32
|
+
'██ ██ ██ ██ ██ ██ ██',
|
|
33
|
+
'██████ ██ ██ ██ ██ ██ ██',
|
|
34
|
+
' ██ ██ ██ ██ ██ ██ ██',
|
|
35
|
+
'██████ ██████ ██ ██ ██████ ██████',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param subtitle what this sidecar is, shown under the wordmark
|
|
40
|
+
* @param version the package version, or an empty string
|
|
41
|
+
* @param tty whether the output is a terminal (drawing) or not (one line)
|
|
42
|
+
* @param colour whether escape codes may be used
|
|
43
|
+
* @returns the banner, newline-terminated, ready to print
|
|
44
|
+
*/
|
|
45
|
+
export function banner(subtitle, {
|
|
46
|
+
version = '',
|
|
47
|
+
tty = Boolean(process.stdout.isTTY),
|
|
48
|
+
colour = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
|
|
49
|
+
} = {}) {
|
|
50
|
+
|
|
51
|
+
const tail = [subtitle, version].filter(Boolean).join(' ')
|
|
52
|
+
if (!tty) {
|
|
53
|
+
return `Sciilo · ${tail}\n`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const paint = (code, text) => (colour ? `${code}${text}${RESET}` : text)
|
|
57
|
+
const lines = MARK.map((mark, row) =>
|
|
58
|
+
` ${paint(SAGE, mark)}${paint(BOLD, WORDMARK[row])}`)
|
|
59
|
+
lines.push('')
|
|
60
|
+
lines.push(` ${paint(SAGE, ' '.repeat(MARK[0].length))}${tail}`)
|
|
61
|
+
lines.push('')
|
|
62
|
+
return `${lines.join('\n')}\n`
|
|
63
|
+
}
|