openplexer 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/src/cli.ts ADDED
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+
3
+ // openplexer CLI entrypoint.
4
+ // Syncs ACP sessions (from OpenCode or Claude Code) to Notion board databases.
5
+ // Uses goke for CLI parsing and clack for interactive prompts.
6
+
7
+ import { goke } from 'goke'
8
+ import {
9
+ intro,
10
+ outro,
11
+ note,
12
+ cancel,
13
+ isCancel,
14
+ confirm,
15
+ log,
16
+ multiselect,
17
+ select,
18
+ spinner,
19
+ } from '@clack/prompts'
20
+ import crypto from 'node:crypto'
21
+ import path from 'node:path'
22
+ import { exec } from 'node:child_process'
23
+ import { readConfig, writeConfig, type OpenplexerConfig, type OpenplexerBoard, type AcpClient } from './config.ts'
24
+ import { connectAcp, listAllSessions, type AcpConnection } from './acp-client.ts'
25
+ import { getRepoInfo } from './git.ts'
26
+ import { createNotionClient, createBoardDatabase, getRootPages } from './notion.ts'
27
+ import { evictExistingInstance, getLockPort, startLockServer } from './lock.ts'
28
+ import { startSyncLoop } from './sync.ts'
29
+ import {
30
+ enableStartupService,
31
+ disableStartupService,
32
+ isStartupServiceEnabled,
33
+ getServiceLocationDescription,
34
+ } from './startup-service.ts'
35
+
36
+ const OPENPLEXER_URL = 'https://openplexer.com'
37
+
38
+ process.title = 'openplexer'
39
+
40
+ const cli = goke('openplexer')
41
+
42
+ // Default command: start sync if boards exist, otherwise run connect wizard
43
+ cli
44
+ .command('', 'Sync coding sessions to Notion boards')
45
+ .action(async () => {
46
+ const config = readConfig()
47
+ if (!config || config.boards.length === 0) {
48
+ await connectFlow()
49
+ return
50
+ }
51
+ await startDaemon(config)
52
+ })
53
+
54
+ // Connect command: add a new board
55
+ cli.command('connect', 'Connect a new Notion board').action(async () => {
56
+ await connectFlow()
57
+ })
58
+
59
+ // Status command: show current sync state
60
+ cli.command('status', 'Show sync state').action(async () => {
61
+ const config = readConfig()
62
+ if (!config || config.boards.length === 0) {
63
+ console.log('No boards configured. Run `openplexer connect` to add one.')
64
+ return
65
+ }
66
+ console.log(`Clients: ${config.clients.join(', ')}`)
67
+ console.log(`Boards: ${config.boards.length}`)
68
+ config.boards.forEach((board, i) => {
69
+ console.log(
70
+ ` ${i + 1}. ${board.notionWorkspaceName} — ${board.trackedRepos.length} repos, ${Object.keys(board.syncedSessions).length} synced sessions`,
71
+ )
72
+ })
73
+ })
74
+
75
+ // Stop command: kill running daemon via lock port
76
+ cli.command('stop', 'Stop the running openplexer daemon').action(async () => {
77
+ const port = getLockPort()
78
+ const probe = await fetch(`http://127.0.0.1:${port}/health`, {
79
+ signal: AbortSignal.timeout(1000),
80
+ }).catch(() => {
81
+ return undefined
82
+ })
83
+
84
+ if (!probe) {
85
+ console.log('No running daemon found.')
86
+ return
87
+ }
88
+
89
+ const body = (await probe.json().catch(() => ({}))) as { pid?: number }
90
+ if (body.pid) {
91
+ process.kill(body.pid, 'SIGTERM')
92
+ console.log(`Stopped daemon (PID ${body.pid})`)
93
+ }
94
+ })
95
+
96
+ // Boards command: list boards
97
+ cli.command('boards', 'List configured boards').action(async () => {
98
+ const config = readConfig()
99
+ if (!config || config.boards.length === 0) {
100
+ console.log('No boards configured.')
101
+ return
102
+ }
103
+ config.boards.forEach((board, i) => {
104
+ console.log(`${i + 1}. ${board.notionWorkspaceName}`)
105
+ console.log(` Page: https://notion.so/${board.notionPageId.replace(/-/g, '')}`)
106
+ console.log(` Repos: ${board.trackedRepos.join(', ') || '(all)'}`)
107
+ console.log(` Synced: ${Object.keys(board.syncedSessions).length} sessions`)
108
+ })
109
+ })
110
+
111
+ // Startup command: manage startup registration
112
+ cli.command('startup', 'Show startup registration status').action(async () => {
113
+ const enabled = await isStartupServiceEnabled()
114
+ if (enabled) {
115
+ console.log(`Registered: ${getServiceLocationDescription()}`)
116
+ } else {
117
+ console.log('Not registered to run on login.')
118
+ }
119
+ })
120
+
121
+ cli
122
+ .command('startup enable', 'Register openplexer to run on login')
123
+ .action(async () => {
124
+ const openplexerBin = path.resolve(process.argv[1])
125
+ await enableStartupService({ command: process.execPath, args: [openplexerBin] })
126
+ console.log(`Registered at ${getServiceLocationDescription()}`)
127
+ })
128
+
129
+ cli
130
+ .command('startup disable', 'Unregister openplexer from login')
131
+ .action(async () => {
132
+ await disableStartupService()
133
+ console.log('Unregistered from login startup.')
134
+ })
135
+
136
+ cli.parse()
137
+
138
+ // --- Connect wizard ---
139
+
140
+ async function connectFlow(): Promise<void> {
141
+ intro('openplexer — connect a Notion board')
142
+
143
+ const config = readConfig() || ({ clients: [], boards: [] } as OpenplexerConfig)
144
+
145
+ // Step 1: Choose ACP clients (only on first run)
146
+ if (config.clients.length === 0) {
147
+ const clientChoice = await multiselect({
148
+ message: 'Which coding agents do you use?',
149
+ options: [
150
+ { value: 'opencode' as const, label: 'OpenCode' },
151
+ { value: 'claude' as const, label: 'Claude Code' },
152
+ ],
153
+ required: true,
154
+ })
155
+ if (isCancel(clientChoice)) {
156
+ cancel('Setup cancelled')
157
+ process.exit(0)
158
+ }
159
+ config.clients = clientChoice
160
+ }
161
+
162
+ // Step 2: Spawn ACP for each client and discover projects
163
+ const s = spinner()
164
+ const clientLabel = config.clients.join(' + ')
165
+ s.start(`Connecting to ${clientLabel}...`)
166
+
167
+ let repoSlugs: string[] = []
168
+ const connectedClients: AcpClient[] = []
169
+
170
+ for (const client of config.clients) {
171
+ try {
172
+ const acp = await connectAcp({ client })
173
+ const sessions = await listAllSessions({ connection: acp.connection })
174
+
175
+ // Extract unique repos from session cwds
176
+ const cwds = [...new Set(sessions.map((sess) => sess.cwd).filter(Boolean))] as string[]
177
+ const repoInfos = await Promise.all(cwds.map((cwd) => getRepoInfo({ cwd })))
178
+ repoSlugs.push(...repoInfos.filter(Boolean).map((r) => r!.slug))
179
+
180
+ acp.kill()
181
+ connectedClients.push(client)
182
+ log.info(`${client}: ${sessions.length} sessions`)
183
+ } catch {
184
+ log.warn(`Could not connect to ${client}. Make sure "${client}" is installed and in PATH.`)
185
+ }
186
+ }
187
+
188
+ repoSlugs = [...new Set(repoSlugs)]
189
+ s.stop(`Found ${repoSlugs.length} repos from ${connectedClients.join(' + ')}`)
190
+
191
+ if (connectedClients.length === 0) {
192
+ log.error('Could not connect to any ACP agent.')
193
+ process.exit(1)
194
+ }
195
+
196
+ // Step 3: Select repos to track
197
+ let trackedRepos: string[] = []
198
+ if (repoSlugs.length > 0) {
199
+ note(
200
+ 'Select specific repos if you plan to collaborate.\nThis avoids showing personal projects on the shared board.',
201
+ 'Repo selection',
202
+ )
203
+ const repoChoice = await multiselect({
204
+ message: 'Which repos to track?',
205
+ options: [
206
+ { value: '*', label: '* All repos', hint: 'sync every repo with a git remote' },
207
+ ...repoSlugs.map((slug) => ({
208
+ value: slug,
209
+ label: slug,
210
+ })),
211
+ ],
212
+ required: false,
213
+ })
214
+ if (isCancel(repoChoice)) {
215
+ cancel('Setup cancelled')
216
+ process.exit(0)
217
+ }
218
+ trackedRepos = repoChoice.includes('*') ? [] : repoChoice
219
+ } else {
220
+ log.warn('No git repos found in sessions. All future sessions will be tracked.')
221
+ }
222
+
223
+ // Step 4: Notion OAuth
224
+ const state = crypto.randomBytes(16).toString('hex')
225
+ const authUrl = `${OPENPLEXER_URL}/auth/notion?state=${state}`
226
+
227
+ note(`Opening browser to connect Notion.\nAuthorize the integration and select a page to share.\n\n${authUrl}`, 'Notion')
228
+
229
+ // Open browser
230
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
231
+ exec(`${openCmd} "${authUrl}"`)
232
+
233
+ s.start('Waiting for Notion authorization...')
234
+
235
+ // Poll for token
236
+ type AuthResult = {
237
+ accessToken: string
238
+ botId: string
239
+ workspaceId: string
240
+ workspaceName: string
241
+ notionUserId?: string
242
+ notionUserName?: string
243
+ }
244
+ let authResult: AuthResult | undefined
245
+ const maxAttempts = 150 // 5 minutes at 2s intervals
246
+ for (let i = 0; i < maxAttempts; i++) {
247
+ await new Promise((resolve) => {
248
+ setTimeout(resolve, 2000)
249
+ })
250
+ const resp = await fetch(`${OPENPLEXER_URL}/auth/status?state=${state}`, {
251
+ signal: AbortSignal.timeout(3000),
252
+ }).catch(() => {
253
+ return undefined
254
+ })
255
+ if (resp?.ok) {
256
+ authResult = (await resp.json()) as AuthResult
257
+ break
258
+ }
259
+ }
260
+
261
+ if (!authResult) {
262
+ s.stop('Timed out waiting for Notion authorization')
263
+ process.exit(1)
264
+ }
265
+
266
+ s.stop(`Connected to ${authResult.workspaceName}`)
267
+
268
+ // Step 5: Select Notion page from root pages
269
+ const notion = createNotionClient({ token: authResult.accessToken })
270
+ s.start('Fetching Notion pages...')
271
+ const rootPages = await getRootPages({ notion })
272
+ s.stop(`Found ${rootPages.length} root pages`)
273
+
274
+ if (rootPages.length === 0) {
275
+ log.error('No root pages found in your Notion workspace. Create a page first.')
276
+ process.exit(1)
277
+ }
278
+
279
+ // Filter out pages already used by other boards
280
+ const usedPageIds = new Set(config.boards.map((b) => b.notionPageId))
281
+ const availablePages = rootPages.filter((p) => !usedPageIds.has(p.id))
282
+
283
+ if (availablePages.length === 0) {
284
+ log.error('All root pages are already connected to boards.')
285
+ process.exit(1)
286
+ }
287
+
288
+ const pageId: string = await (async () => {
289
+ if (availablePages.length === 1) {
290
+ log.info(`Auto-selected page: ${availablePages[0].icon} ${availablePages[0].title}`)
291
+ return availablePages[0].id
292
+ }
293
+ const pageChoice = await select({
294
+ message: 'Which Notion page should hold the board?',
295
+ options: availablePages.map((p) => ({
296
+ value: p.id,
297
+ label: `${p.icon} ${p.title}`.trim(),
298
+ hint: usedPageIds.has(p.id) ? 'already used' : undefined,
299
+ })),
300
+ })
301
+ if (isCancel(pageChoice)) {
302
+ cancel('Setup cancelled')
303
+ process.exit(0)
304
+ }
305
+ return pageChoice
306
+ })()
307
+
308
+ // Step 6: Create database
309
+ s.start('Creating board database...')
310
+ const { databaseId } = await createBoardDatabase({ notion, pageId })
311
+ s.stop('Board database created')
312
+
313
+ log.success('Open the database in Notion and click "+ Add a view" → Board, grouped by Status.')
314
+
315
+ // Step 7: Save to config
316
+ const board: OpenplexerBoard = {
317
+ notionToken: authResult.accessToken,
318
+ notionUserId: authResult.notionUserId || '',
319
+ notionUserName: authResult.notionUserName || '',
320
+ notionWorkspaceId: authResult.workspaceId,
321
+ notionWorkspaceName: authResult.workspaceName,
322
+ notionPageId: pageId,
323
+ notionDatabaseId: databaseId,
324
+ trackedRepos,
325
+ syncedSessions: {},
326
+ connectedAt: new Date().toISOString(),
327
+ }
328
+
329
+ config.boards.push(board)
330
+ writeConfig(config)
331
+
332
+ // Resolve absolute path to the CLI script so startup service and
333
+ // detached spawn work regardless of cwd at login/invocation time.
334
+ const openplexerBin = path.resolve(process.argv[1])
335
+
336
+ // Step 8: Offer startup registration
337
+ const alreadyEnabled = await isStartupServiceEnabled()
338
+ if (!alreadyEnabled) {
339
+ const registerStartup = await confirm({
340
+ message: 'Register openplexer to run on login?',
341
+ })
342
+ if (!isCancel(registerStartup) && registerStartup) {
343
+ await enableStartupService({ command: process.execPath, args: [openplexerBin] })
344
+ log.success(`Registered at ${getServiceLocationDescription()}`)
345
+ }
346
+ } else {
347
+ log.info(`Already registered at ${getServiceLocationDescription()}`)
348
+ }
349
+
350
+ // Step 9: Spawn daemon in background so syncing starts immediately
351
+ const { spawn: spawnProcess } = await import('node:child_process')
352
+ const child = spawnProcess(process.execPath, [openplexerBin], {
353
+ detached: true,
354
+ stdio: 'ignore',
355
+ })
356
+ child.unref()
357
+
358
+ outro('Board connected! Sync daemon started in background.')
359
+ }
360
+
361
+ // --- Daemon ---
362
+
363
+ async function startDaemon(config: OpenplexerConfig): Promise<void> {
364
+ const port = getLockPort()
365
+ await evictExistingInstance({ port })
366
+ startLockServer({ port })
367
+
368
+ console.log(`openplexer daemon started (PID ${process.pid}, port ${port})`)
369
+
370
+ const connections: AcpConnection[] = []
371
+ for (const client of config.clients) {
372
+ try {
373
+ const acp = await connectAcp({ client })
374
+ connections.push(acp)
375
+ console.log(`Connected to ${client} via ACP`)
376
+ } catch {
377
+ console.error(`Failed to connect to ${client}, skipping`)
378
+ }
379
+ }
380
+
381
+ if (connections.length === 0) {
382
+ console.error('Could not connect to any ACP agent.')
383
+ process.exit(1)
384
+ }
385
+
386
+ await startSyncLoop({ config, acpConnections: connections })
387
+ }
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Typed config for openplexer, stored at ~/.openplexer/config.json.
2
+ // Supports multiple boards — each board is a separate Notion database
3
+ // that this CLI syncs ACP sessions to.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import os from 'node:os'
8
+
9
+ export type OpenplexerBoard = {
10
+ /** Notion OAuth access token */
11
+ notionToken: string
12
+ /** Notion user ID of this machine's user */
13
+ notionUserId: string
14
+ /** Notion user name */
15
+ notionUserName: string
16
+ /** Notion workspace ID */
17
+ notionWorkspaceId: string
18
+ /** Notion workspace name */
19
+ notionWorkspaceName: string
20
+ /** Notion page ID where database was created */
21
+ notionPageId: string
22
+ /** Notion database ID (created by CLI) */
23
+ notionDatabaseId: string
24
+ /** Git repo URLs to track (e.g. ["owner/repo1", "owner/repo2"]) */
25
+ trackedRepos: string[]
26
+ /** Map of ACP session ID → Notion page ID (already synced) */
27
+ syncedSessions: Record<string, string>
28
+ /** ISO timestamp of when this board was connected. Only sessions
29
+ * created or last updated after this time are synced. */
30
+ connectedAt: string
31
+ }
32
+
33
+ export type AcpClient = 'opencode' | 'claude'
34
+
35
+ export type OpenplexerConfig = {
36
+ /** ACP clients to connect to (user may use both opencode and claude) */
37
+ clients: AcpClient[]
38
+ /** Multiple boards this CLI syncs to */
39
+ boards: OpenplexerBoard[]
40
+ }
41
+
42
+ const CONFIG_DIR = path.join(os.homedir(), '.openplexer')
43
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
44
+
45
+ export function getConfigDir(): string {
46
+ return CONFIG_DIR
47
+ }
48
+
49
+ export function readConfig(): OpenplexerConfig | undefined {
50
+ try {
51
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8')
52
+ return JSON.parse(raw) as OpenplexerConfig
53
+ } catch {
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ export function writeConfig(config: OpenplexerConfig): void {
59
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
60
+ const tmpFile = CONFIG_FILE + '.tmp'
61
+ fs.writeFileSync(tmpFile, JSON.stringify(config, null, 2))
62
+ fs.renameSync(tmpFile, CONFIG_FILE)
63
+ }
package/src/env.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Typed environment variables for the Cloudflare Worker.
2
+ // NOTION_CLIENT_ID and NOTION_CLIENT_SECRET are the openplexer Notion
3
+ // integration's OAuth2 credentials, used to exchange auth codes for tokens.
4
+
5
+ export type Env = {
6
+ OPENPLEXER_KV: KVNamespace
7
+ NOTION_CLIENT_ID: string
8
+ NOTION_CLIENT_SECRET: string
9
+ }
package/src/git.ts ADDED
@@ -0,0 +1,84 @@
1
+ // Extract git repo info from session cwd paths.
2
+ // Parses the remote origin URL to get owner/repo.
3
+
4
+ import { execFile } from 'node:child_process'
5
+
6
+ export type RepoInfo = {
7
+ owner: string
8
+ repo: string
9
+ /** e.g. "owner/repo" */
10
+ slug: string
11
+ /** Full GitHub URL */
12
+ url: string
13
+ /** Current branch name */
14
+ branch: string
15
+ }
16
+
17
+ export async function getRepoInfo({ cwd }: { cwd: string }): Promise<RepoInfo | undefined> {
18
+ const remoteUrl = await execAsync('git', ['-C', cwd, 'remote', 'get-url', 'origin']).catch(
19
+ () => {
20
+ return undefined
21
+ },
22
+ )
23
+ if (!remoteUrl) {
24
+ return undefined
25
+ }
26
+
27
+ const parsed = parseGitRemoteUrl(remoteUrl.trim())
28
+ if (!parsed) {
29
+ return undefined
30
+ }
31
+
32
+ const branch = await execAsync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD']).catch(
33
+ () => {
34
+ return 'main'
35
+ },
36
+ )
37
+
38
+ return {
39
+ ...parsed,
40
+ branch: branch.trim(),
41
+ }
42
+ }
43
+
44
+ function parseGitRemoteUrl(url: string): { owner: string; repo: string; slug: string; url: string } | undefined {
45
+ // SSH: git@github.com:owner/repo.git
46
+ const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+)/)
47
+ if (sshMatch) {
48
+ const owner = sshMatch[1]
49
+ const repo = sshMatch[2]
50
+ return {
51
+ owner,
52
+ repo,
53
+ slug: `${owner}/${repo}`,
54
+ url: `https://github.com/${owner}/${repo}`,
55
+ }
56
+ }
57
+
58
+ // HTTPS: https://github.com/owner/repo.git
59
+ const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
60
+ if (httpsMatch) {
61
+ const owner = httpsMatch[1]
62
+ const repo = httpsMatch[2]
63
+ return {
64
+ owner,
65
+ repo,
66
+ slug: `${owner}/${repo}`,
67
+ url: `https://github.com/${owner}/${repo}`,
68
+ }
69
+ }
70
+
71
+ return undefined
72
+ }
73
+
74
+ function execAsync(cmd: string, args: string[]): Promise<string> {
75
+ return new Promise((resolve, reject) => {
76
+ execFile(cmd, args, { timeout: 5000 }, (error, stdout) => {
77
+ if (error) {
78
+ reject(error)
79
+ return
80
+ }
81
+ resolve(stdout)
82
+ })
83
+ })
84
+ }
package/src/lock.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Single-instance enforcement via lock port.
2
+ // Same pattern as kimaki's hrana-server.ts: probe /health, SIGTERM, SIGKILL.
3
+
4
+ import http from 'node:http'
5
+
6
+ const DEFAULT_LOCK_PORT = 29990
7
+
8
+ export function getLockPort(): number {
9
+ const envPort = process.env['OPENPLEXER_LOCK_PORT']
10
+ if (envPort) {
11
+ const parsed = Number.parseInt(envPort, 10)
12
+ if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
13
+ return parsed
14
+ }
15
+ }
16
+ return DEFAULT_LOCK_PORT
17
+ }
18
+
19
+ export async function evictExistingInstance({ port }: { port: number }): Promise<void> {
20
+ const url = `http://127.0.0.1:${port}/health`
21
+ const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch(() => {
22
+ return undefined
23
+ })
24
+ if (!probe) {
25
+ return
26
+ }
27
+
28
+ const body = (await probe.json().catch(() => ({}))) as { pid?: number }
29
+ const targetPid = body.pid
30
+ if (!targetPid || targetPid === process.pid) {
31
+ return
32
+ }
33
+
34
+ process.kill(targetPid, 'SIGTERM')
35
+ await new Promise((resolve) => {
36
+ setTimeout(resolve, 1000)
37
+ })
38
+
39
+ const secondProbe = await fetch(url, { signal: AbortSignal.timeout(500) }).catch(() => {
40
+ return undefined
41
+ })
42
+ if (!secondProbe) {
43
+ return
44
+ }
45
+
46
+ process.kill(targetPid, 'SIGKILL')
47
+ await new Promise((resolve) => {
48
+ setTimeout(resolve, 1000)
49
+ })
50
+ }
51
+
52
+ export function startLockServer({ port }: { port: number }): http.Server {
53
+ const server = http.createServer((req, res) => {
54
+ if (req.url === '/health') {
55
+ res.writeHead(200, { 'Content-Type': 'application/json' })
56
+ res.end(JSON.stringify({ pid: process.pid, status: 'ok' }))
57
+ return
58
+ }
59
+ res.writeHead(404)
60
+ res.end()
61
+ })
62
+
63
+ server.listen(port, '127.0.0.1')
64
+ return server
65
+ }