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.
- package/bin/pinchpoint.mjs +292 -0
- 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
|
+
}
|