@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.
@@ -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
+ }