pinchpoint 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.
Files changed (2) hide show
  1. package/bin/pinchpoint.mjs +292 -0
  2. package/package.json +13 -0
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ // PinchPoint CLI — `npx pinchpoint connect`
3
+ // Zero dependencies. Performs OAuth flow to get a 1-year Claude token,
4
+ // then links it to PinchPoint via polling-based approval.
5
+
6
+ import { createServer } from 'node:http'
7
+ import { createHash, randomBytes, randomInt } from 'node:crypto'
8
+ import { execFileSync } from 'node:child_process'
9
+
10
+ const API_URL = process.env.PINCHPOINT_API_URL || 'https://api.pinchpoint.dev'
11
+ const FRONTEND_URL = process.env.PINCHPOINT_FRONTEND_URL || 'https://pinchpoint.dev'
12
+
13
+ // Claude OAuth (public PKCE client — same as `claude setup-token`)
14
+ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
15
+ const AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
16
+ const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
17
+ const TOKEN_LIFETIME = 31536000 // 1 year in seconds
18
+
19
+ // Prevent token transmission over unencrypted connections
20
+ if (!API_URL.startsWith('https://') && !API_URL.startsWith('http://localhost')) {
21
+ console.error(' \x1b[31mAPI URL must use HTTPS\x1b[39m')
22
+ process.exit(1)
23
+ }
24
+
25
+ // ─── Helpers ─────────────────────────────────────────────────────
26
+
27
+ const bold = t => `\x1b[1m${t}\x1b[22m`
28
+ const dim = t => `\x1b[2m${t}\x1b[22m`
29
+ const green = t => `\x1b[32m${t}\x1b[39m`
30
+ const red = t => `\x1b[31m${t}\x1b[39m`
31
+ const cyan = t => `\x1b[36m${t}\x1b[39m`
32
+
33
+ function log(msg = '') { process.stdout.write(` ${msg}\n`) }
34
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
35
+
36
+ function openBrowser(url) {
37
+ try {
38
+ if (process.platform === 'win32') {
39
+ // Escape & as ^& for cmd.exe (& is a command separator)
40
+ execFileSync('cmd', ['/c', 'start', '', url.replace(/&/g, '^&')], { stdio: 'ignore' })
41
+ } else {
42
+ execFileSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { stdio: 'ignore' })
43
+ }
44
+ } catch {
45
+ log(`Open this URL in your browser: ${cyan(url)}`)
46
+ }
47
+ }
48
+
49
+ // ─── OAuth flow (get 1-year token) ──────────────────────────────
50
+
51
+ async function performOAuthFlow() {
52
+ // PKCE parameters
53
+ const codeVerifier = randomBytes(32).toString('base64url')
54
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
55
+ const state = randomBytes(32).toString('base64url')
56
+
57
+ // Pre-generate PinchPoint verification code + hash (needed inside callback)
58
+ const verificationCode = String(randomInt(1000, 10000))
59
+ const codeHash = createHash('sha256').update(verificationCode).digest('hex')
60
+
61
+ return new Promise((resolve, reject) => {
62
+ let settled = false
63
+
64
+ const server = createServer(async (req, res) => {
65
+ const url = new URL(req.url, `http://localhost:${server.address().port}`)
66
+
67
+ if (url.pathname !== '/callback') {
68
+ res.writeHead(404)
69
+ res.end()
70
+ return
71
+ }
72
+
73
+ // Verify state
74
+ if (url.searchParams.get('state') !== state) {
75
+ res.writeHead(400, { 'Content-Type': 'text/html' })
76
+ res.end('<html><body><p>State mismatch. Close this tab and try again.</p></body></html>')
77
+ return
78
+ }
79
+
80
+ const authCode = url.searchParams.get('code')
81
+ if (!authCode) {
82
+ res.writeHead(400, { 'Content-Type': 'text/html' })
83
+ res.end('<html><body><p>No authorization code received. Close this tab and try again.</p></body></html>')
84
+ return
85
+ }
86
+
87
+ try {
88
+ // Exchange auth code for 1-year token
89
+ const tokenRes = await fetch(TOKEN_URL, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({
93
+ grant_type: 'authorization_code',
94
+ code: authCode,
95
+ redirect_uri: `http://localhost:${server.address().port}/callback`,
96
+ client_id: CLIENT_ID,
97
+ code_verifier: codeVerifier,
98
+ state,
99
+ expires_in: TOKEN_LIFETIME,
100
+ }),
101
+ })
102
+
103
+ if (!tokenRes.ok) {
104
+ const errText = await tokenRes.text().catch(() => 'Unknown error')
105
+ res.writeHead(200, { 'Content-Type': 'text/html' })
106
+ res.end('<html><body><p>Failed to get token from Claude. Close this tab and try again.</p></body></html>')
107
+ server.close()
108
+ if (!settled) { settled = true; reject(new Error(`Token exchange failed (${tokenRes.status}): ${errText}`)) }
109
+ return
110
+ }
111
+
112
+ const { access_token } = await tokenRes.json()
113
+ if (!access_token) {
114
+ res.writeHead(200, { 'Content-Type': 'text/html' })
115
+ res.end('<html><body><p>No token received. Close this tab and try again.</p></body></html>')
116
+ server.close()
117
+ if (!settled) { settled = true; reject(new Error('Token exchange returned no access_token')) }
118
+ return
119
+ }
120
+
121
+ // Start PinchPoint connect session
122
+ const tokenFingerprint = createHash('sha256').update(access_token).digest('hex').slice(0, 32)
123
+ const startRes = await fetch(`${API_URL}/api/connect/start`, {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify({ tokenFingerprint, codeHash }),
127
+ })
128
+
129
+ if (!startRes.ok) {
130
+ res.writeHead(200, { 'Content-Type': 'text/html' })
131
+ res.end('<html><body><p>Failed to connect to PinchPoint. Close this tab and try again.</p></body></html>')
132
+ server.close()
133
+ if (!settled) { settled = true; reject(new Error(`Connect start failed (${startRes.status})`)) }
134
+ return
135
+ }
136
+
137
+ const { sessionId } = await startRes.json()
138
+
139
+ // Redirect browser to PinchPoint connect page
140
+ res.writeHead(302, { Location: `${FRONTEND_URL}/connect?session=${sessionId}` })
141
+ res.end()
142
+
143
+ server.close()
144
+ if (!settled) {
145
+ settled = true
146
+ resolve({ token: access_token, sessionId, verificationCode, tokenFingerprint })
147
+ }
148
+ } catch (err) {
149
+ res.writeHead(200, { 'Content-Type': 'text/html' })
150
+ res.end('<html><body><p>Something went wrong. Close this tab and try again.</p></body></html>')
151
+ server.close()
152
+ if (!settled) { settled = true; reject(err) }
153
+ }
154
+ })
155
+
156
+ server.listen(0, '127.0.0.1', () => {
157
+ const port = server.address().port
158
+
159
+ // Build authorization URL
160
+ const authUrl = new URL(AUTHORIZE_URL)
161
+ authUrl.searchParams.set('code', 'true')
162
+ authUrl.searchParams.set('client_id', CLIENT_ID)
163
+ authUrl.searchParams.set('response_type', 'code')
164
+ authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)
165
+ authUrl.searchParams.set('scope', 'user:inference')
166
+ authUrl.searchParams.set('code_challenge', codeChallenge)
167
+ authUrl.searchParams.set('code_challenge_method', 'S256')
168
+ authUrl.searchParams.set('state', state)
169
+
170
+ process.stdout.write(`\r ${dim('Opening Claude authorization...')}\n`)
171
+ openBrowser(authUrl.toString())
172
+ })
173
+
174
+ server.on('error', (err) => {
175
+ if (!settled) { settled = true; reject(err) }
176
+ })
177
+
178
+ // 5-minute timeout for the entire OAuth flow
179
+ setTimeout(() => {
180
+ server.close()
181
+ if (!settled) { settled = true; reject(new Error('Authorization timed out. Run the command again.')) }
182
+ }, 5 * 60 * 1000)
183
+ })
184
+ }
185
+
186
+ // ─── Connect flow ───────────────────────────────────────────────
187
+
188
+ async function connect() {
189
+ console.log()
190
+ log(bold('PinchPoint Connect'))
191
+ log(dim('Link your Claude credentials\n'))
192
+
193
+ // Step 1: OAuth flow → 1-year token + PinchPoint session + browser redirect
194
+ const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
195
+ let i = 0
196
+ const spinAuth = setInterval(() => {
197
+ process.stdout.write(`\r ${spinner[i++ % spinner.length]} Waiting for authorization...`)
198
+ }, 100)
199
+
200
+ let result
201
+ try {
202
+ result = await performOAuthFlow()
203
+ } finally {
204
+ clearInterval(spinAuth)
205
+ }
206
+
207
+ const { token, sessionId, verificationCode, tokenFingerprint } = result
208
+ process.stdout.write(`\r ${green('✓')} Authorized! \n`)
209
+
210
+ // Step 2: Show verification code (browser is already at PinchPoint)
211
+ log()
212
+ log(bold('Verification code:'))
213
+ log()
214
+ log(` ${bold(cyan(verificationCode.split('').join(' ')))}`)
215
+ log()
216
+ log(dim('Enter this code in the browser to confirm the connection.'))
217
+ log()
218
+
219
+ // Step 3: Poll for approval
220
+ i = 0
221
+ const deadline = Date.now() + 5 * 60 * 1000
222
+
223
+ while (Date.now() < deadline) {
224
+ process.stdout.write(`\r ${spinner[i++ % spinner.length]} Waiting for approval...`)
225
+ await sleep(2000)
226
+
227
+ let poll
228
+ try {
229
+ const res = await fetch(`${API_URL}/api/connect/poll?session=${sessionId}`)
230
+ poll = await res.json()
231
+ } catch {
232
+ continue // Network blip
233
+ }
234
+
235
+ if (poll.status === 'approved') {
236
+ process.stdout.write(`\r ${green('✓')} Approved! \n`)
237
+
238
+ // Step 4: Send token
239
+ process.stdout.write(' Sending credentials... ')
240
+ const completeRes = await fetch(`${API_URL}/api/connect/complete`, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ sessionId, setupToken: token, tokenFingerprint }),
244
+ })
245
+
246
+ if (!completeRes.ok) {
247
+ const err = await completeRes.json().catch(() => ({}))
248
+ process.stdout.write(red('failed') + '\n')
249
+ log(dim(err.error || 'Unknown error'))
250
+ process.exit(1)
251
+ }
252
+
253
+ process.stdout.write(green('done') + '\n')
254
+ log()
255
+ log(green('Connected successfully!'))
256
+ log(dim('Set your schedule at ') + cyan(FRONTEND_URL))
257
+ log()
258
+ process.exit(0)
259
+ }
260
+
261
+ if (poll.status === 'expired') {
262
+ process.stdout.write(`\r ${red('✗')} Session expired. \n`)
263
+ log(dim('Run the command again.'))
264
+ process.exit(1)
265
+ }
266
+ }
267
+
268
+ process.stdout.write(`\r ${red('✗')} Timed out. \n`)
269
+ log(dim('No approval received within 5 minutes.'))
270
+ process.exit(1)
271
+ }
272
+
273
+ // ─── Entry ───────────────────────────────────────────────────────
274
+
275
+ const command = process.argv[2]
276
+
277
+ if (command === 'connect') {
278
+ connect().catch(e => {
279
+ log(red(`Error: ${e.message}`))
280
+ process.exit(1)
281
+ })
282
+ } else {
283
+ console.log()
284
+ log(bold('PinchPoint CLI'))
285
+ log()
286
+ log(`Usage: ${cyan('npx pinchpoint connect')}`)
287
+ log()
288
+ log('Connects your Claude Pro/Max account to PinchPoint')
289
+ log('so your 5-hour usage window starts on your schedule.')
290
+ log()
291
+ if (command) process.exit(1)
292
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "pinchpoint",
3
+ "version": "0.1.0",
4
+ "description": "Connect your Claude credentials to PinchPoint",
5
+ "type": "module",
6
+ "bin": {
7
+ "pinchpoint": "./bin/pinchpoint.mjs"
8
+ },
9
+ "files": ["bin/"],
10
+ "keywords": ["claude", "anthropic", "scheduler"],
11
+ "license": "MIT",
12
+ "engines": { "node": ">=18.0.0" }
13
+ }