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/dist/worker.js ADDED
@@ -0,0 +1,151 @@
1
+ // Cloudflare Worker entrypoint for openplexer.
2
+ // Handles Notion OAuth flow: redirects user to Notion, receives callback
3
+ // with auth code, exchanges it for tokens, and stores result in KV for
4
+ // the CLI to poll. Same pattern as kimaki's gateway onboarding.
5
+ import { Spiceflow } from 'spiceflow';
6
+ const REDIRECT_PATH = '/auth/callback';
7
+ const app = new Spiceflow()
8
+ .state('env', {})
9
+ .onError(({ error }) => {
10
+ console.error(error);
11
+ const message = error instanceof Error ? error.message : String(error);
12
+ return new Response(message, { status: 500 });
13
+ })
14
+ .route({
15
+ method: 'GET',
16
+ path: '/',
17
+ handler() {
18
+ return new Response(null, {
19
+ status: 302,
20
+ headers: { Location: 'https://github.com/remorses/kimaki/tree/main/openplexer' },
21
+ });
22
+ },
23
+ })
24
+ .route({
25
+ method: 'GET',
26
+ path: '/health',
27
+ handler() {
28
+ return { status: 'ok' };
29
+ },
30
+ })
31
+ // Step 1: CLI opens browser to this URL. We redirect to Notion OAuth.
32
+ // The CLI passes a random `state` param to correlate the callback.
33
+ .route({
34
+ method: 'GET',
35
+ path: '/auth/notion',
36
+ handler({ request, state }) {
37
+ const url = new URL(request.url);
38
+ const stateParam = url.searchParams.get('state');
39
+ if (!stateParam) {
40
+ return new Response('Missing state parameter', { status: 400 });
41
+ }
42
+ const env = state.env;
43
+ const notionAuthUrl = new URL('https://api.notion.com/v1/oauth/authorize');
44
+ notionAuthUrl.searchParams.set('client_id', env.NOTION_CLIENT_ID);
45
+ notionAuthUrl.searchParams.set('response_type', 'code');
46
+ notionAuthUrl.searchParams.set('owner', 'user');
47
+ notionAuthUrl.searchParams.set('redirect_uri', new URL(REDIRECT_PATH, url.origin).toString());
48
+ notionAuthUrl.searchParams.set('state', stateParam);
49
+ return new Response(null, {
50
+ status: 302,
51
+ headers: { Location: notionAuthUrl.toString() },
52
+ });
53
+ },
54
+ })
55
+ // Step 2: Notion redirects here after user authorizes.
56
+ // We exchange the code for tokens and store in KV.
57
+ .route({
58
+ method: 'GET',
59
+ path: REDIRECT_PATH,
60
+ async handler({ request, state }) {
61
+ const url = new URL(request.url);
62
+ const code = url.searchParams.get('code');
63
+ const stateParam = url.searchParams.get('state');
64
+ if (!code || !stateParam) {
65
+ return new Response('Missing code or state parameter', { status: 400 });
66
+ }
67
+ const env = state.env;
68
+ const redirectUri = new URL(REDIRECT_PATH, url.origin).toString();
69
+ // Exchange code for tokens
70
+ const encoded = btoa(`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`);
71
+ const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
72
+ method: 'POST',
73
+ headers: {
74
+ Accept: 'application/json',
75
+ 'Content-Type': 'application/json',
76
+ Authorization: `Basic ${encoded}`,
77
+ },
78
+ body: JSON.stringify({
79
+ grant_type: 'authorization_code',
80
+ code,
81
+ redirect_uri: redirectUri,
82
+ }),
83
+ });
84
+ if (!tokenResponse.ok) {
85
+ const errorBody = await tokenResponse.text();
86
+ console.error('Notion token exchange failed:', errorBody);
87
+ return new Response(`Notion authorization failed: ${errorBody}`, { status: 500 });
88
+ }
89
+ const tokenData = (await tokenResponse.json());
90
+ // Store tokens in KV with 5 minute TTL
91
+ const kvPayload = {
92
+ accessToken: tokenData.access_token,
93
+ botId: tokenData.bot_id,
94
+ workspaceId: tokenData.workspace_id,
95
+ workspaceName: tokenData.workspace_name,
96
+ notionUserId: tokenData.owner?.user?.id,
97
+ notionUserName: tokenData.owner?.user?.name,
98
+ };
99
+ await env.OPENPLEXER_KV.put(`auth:${stateParam}`, JSON.stringify(kvPayload), {
100
+ expirationTtl: 300,
101
+ });
102
+ // Show success page
103
+ return new Response(`<!DOCTYPE html>
104
+ <html>
105
+ <head><title>openplexer - Connected</title>
106
+ <style>
107
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
108
+ align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
109
+ .card { text-align: center; padding: 48px; border-radius: 12px;
110
+ background: #16213e; max-width: 400px; }
111
+ h1 { margin: 0 0 16px; font-size: 24px; }
112
+ p { color: #999; margin: 0; }
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <div class="card">
117
+ <h1>Connected to Notion</h1>
118
+ <p>You can close this tab and return to the CLI.</p>
119
+ </div>
120
+ </body>
121
+ </html>`, { status: 200, headers: { 'Content-Type': 'text/html' } });
122
+ },
123
+ })
124
+ // Step 3: CLI polls this endpoint to get the tokens.
125
+ .route({
126
+ method: 'GET',
127
+ path: '/auth/status',
128
+ async handler({ request, state }) {
129
+ const url = new URL(request.url);
130
+ const stateParam = url.searchParams.get('state');
131
+ if (!stateParam) {
132
+ return new Response('Missing state parameter', { status: 400 });
133
+ }
134
+ const result = await state.env.OPENPLEXER_KV.get(`auth:${stateParam}`);
135
+ if (!result) {
136
+ return new Response(JSON.stringify({ status: 'pending' }), {
137
+ status: 404,
138
+ headers: { 'Content-Type': 'application/json' },
139
+ });
140
+ }
141
+ return new Response(result, {
142
+ status: 200,
143
+ headers: { 'Content-Type': 'application/json' },
144
+ });
145
+ },
146
+ });
147
+ export default {
148
+ fetch(request, env) {
149
+ return app.handle(request, { state: { env } });
150
+ },
151
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "openplexer",
3
+ "version": "0.1.0",
4
+ "description": "Track coding sessions in Notion boards. Syncs ACP sessions from OpenCode and Claude Code to collaborative Notion databases.",
5
+ "type": "module",
6
+ "main": "./dist/cli.js",
7
+ "types": "./dist/cli.d.ts",
8
+ "bin": "dist/cli.js",
9
+ "exports": {
10
+ "./package.json": "./package.json",
11
+ ".": {
12
+ "types": "./dist/cli.d.ts",
13
+ "default": "./dist/cli.js"
14
+ },
15
+ "./src": {
16
+ "types": "./src/cli.ts",
17
+ "default": "./src/cli.ts"
18
+ },
19
+ "./src/*": {
20
+ "types": "./src/*.ts",
21
+ "default": "./src/*.ts"
22
+ }
23
+ },
24
+ "files": [
25
+ "src",
26
+ "dist",
27
+ "README.md"
28
+ ],
29
+ "dependencies": {
30
+ "@agentclientprotocol/sdk": "^0.16.1",
31
+ "@clack/prompts": "^0.10.0",
32
+ "@notionhq/client": "^5.13.0",
33
+ "goke": "^6.3.0",
34
+ "spiceflow": "1.18.0-rsc.11",
35
+ "errore": "^0.14.1"
36
+ },
37
+ "devDependencies": {
38
+ "@cloudflare/workers-types": "^4.20260130.0",
39
+ "@types/node": "^22.0.0",
40
+ "tsx": "^4.21.0",
41
+ "typescript": "5.8.3",
42
+ "wrangler": "^4.61.1"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/remorses/kimaki",
47
+ "directory": "openplexer"
48
+ },
49
+ "homepage": "https://openplexer.com",
50
+ "bugs": "https://github.com/remorses/kimaki/issues",
51
+ "keywords": [
52
+ "notion",
53
+ "coding-sessions",
54
+ "acp",
55
+ "opencode",
56
+ "claude",
57
+ "session-tracker"
58
+ ],
59
+ "scripts": {
60
+ "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
61
+ "dev": "doppler run --mount .dev.vars --mount-format env -- wrangler dev --port 8790",
62
+ "deployment": "tsc --noEmit && wrangler deploy --env preview",
63
+ "deployment:production": "tsc --noEmit && wrangler deploy",
64
+ "secrets:prod": "doppler run -c production --mount .env.prod --mount-format env -- wrangler secret bulk .env.prod"
65
+ }
66
+ }
@@ -0,0 +1,124 @@
1
+ // Spawn an ACP agent (opencode or claude) as a child process and connect
2
+ // as a client via stdio. Uses @agentclientprotocol/sdk for the protocol.
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { Writable, Readable } from 'node:stream'
6
+ import {
7
+ ClientSideConnection,
8
+ ndJsonStream,
9
+ type Agent,
10
+ type Client,
11
+ type SessionInfo,
12
+ } from '@agentclientprotocol/sdk'
13
+
14
+ function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
15
+ return new WritableStream<Uint8Array>({
16
+ write(chunk) {
17
+ return new Promise<void>((resolve, reject) => {
18
+ nodeStream.write(Buffer.from(chunk), (err) => {
19
+ if (err) {
20
+ reject(err)
21
+ } else {
22
+ resolve()
23
+ }
24
+ })
25
+ })
26
+ },
27
+ })
28
+ }
29
+
30
+ function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
31
+ return new ReadableStream<Uint8Array>({
32
+ start(controller) {
33
+ nodeStream.on('data', (chunk: Buffer) => {
34
+ controller.enqueue(new Uint8Array(chunk))
35
+ })
36
+ nodeStream.on('end', () => {
37
+ controller.close()
38
+ })
39
+ nodeStream.on('error', (err) => {
40
+ controller.error(err)
41
+ })
42
+ },
43
+ })
44
+ }
45
+
46
+ // Minimal Client implementation — we only need session listing,
47
+ // not file ops or permissions. requestPermission and sessionUpdate
48
+ // are required by the Client interface.
49
+ class MinimalClient implements Client {
50
+ async requestPermission() {
51
+ return { outcome: { outcome: 'cancelled' as const } }
52
+ }
53
+ async sessionUpdate() {}
54
+ async readTextFile() {
55
+ return { content: '' }
56
+ }
57
+ async writeTextFile() {
58
+ return {}
59
+ }
60
+ }
61
+
62
+ export type AcpConnection = {
63
+ connection: ClientSideConnection
64
+ client: 'opencode' | 'claude'
65
+ kill: () => void
66
+ }
67
+
68
+ export async function connectAcp({
69
+ client,
70
+ }: {
71
+ client: 'opencode' | 'claude'
72
+ }): Promise<AcpConnection> {
73
+ const cmd = client === 'opencode' ? 'opencode' : 'claude'
74
+ const args = ['acp']
75
+
76
+ const child = spawn(cmd, args, {
77
+ stdio: ['pipe', 'pipe', 'inherit'],
78
+ })
79
+
80
+ const stream = ndJsonStream(
81
+ nodeToWebWritable(child.stdin!),
82
+ nodeToWebReadable(child.stdout!),
83
+ )
84
+
85
+ const connection = new ClientSideConnection((_agent: Agent) => {
86
+ return new MinimalClient()
87
+ }, stream)
88
+
89
+ await connection.initialize({
90
+ protocolVersion: 1,
91
+ clientCapabilities: {},
92
+ })
93
+
94
+ return {
95
+ connection,
96
+ client,
97
+ kill: () => {
98
+ child.kill()
99
+ },
100
+ }
101
+ }
102
+
103
+ export async function listAllSessions({
104
+ connection,
105
+ }: {
106
+ connection: ClientSideConnection
107
+ }): Promise<SessionInfo[]> {
108
+ const sessions: SessionInfo[] = []
109
+ let cursor: string | undefined
110
+
111
+ // Paginate through all sessions
112
+ while (true) {
113
+ const response = await connection.listSessions({
114
+ ...(cursor ? { cursor } : {}),
115
+ })
116
+ sessions.push(...response.sessions)
117
+ if (!response.nextCursor) {
118
+ break
119
+ }
120
+ cursor = response.nextCursor
121
+ }
122
+
123
+ return sessions
124
+ }