@jackpickard/hexgrid-cli 0.0.3

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 (3) hide show
  1. package/README.md +35 -0
  2. package/bin/hexgrid.mjs +435 -0
  3. package/package.json +39 -0
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @jackpickard/hexgrid-cli
2
+
3
+ HexGrid command line client for device login and repo session lifecycle.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @jackpickard/hexgrid-cli
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ hexgrid login
15
+ hexgrid connect --runtime claude
16
+ hexgrid heartbeat
17
+ hexgrid disconnect
18
+ hexgrid me
19
+ hexgrid logout
20
+ ```
21
+
22
+ ## Login flow
23
+
24
+ `hexgrid login` uses a browser-based device flow:
25
+
26
+ 1. CLI prints an approval URL and code
27
+ 2. User approves in browser (`/device`)
28
+ 3. CLI stores token in `~/.config/hexgrid/config.json`
29
+
30
+ ## Runtime flags
31
+
32
+ - `--api-url <url>` override API base URL
33
+ - `--runtime <name>` set session runtime tag (`claude`, `codex`, etc.)
34
+ - `--name <name>` override generated session name
35
+ - `--description <text>` override generated session description
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { spawn, spawnSync } from 'node:child_process'
7
+ import process from 'node:process'
8
+
9
+ const DEFAULT_API_URL = process.env.HEXGRID_API_URL ?? 'https://api.hexgrid.app'
10
+ const CONFIG_PATH = path.join(os.homedir(), '.config', 'hexgrid', 'config.json')
11
+ const TOOL_CANDIDATES = ['git', 'rg', 'npm', 'pnpm', 'bun', 'yarn', 'docker', 'pytest', 'go', 'cargo', 'node', 'python3']
12
+
13
+ async function fileExists(filePath) {
14
+ try {
15
+ await access(filePath)
16
+ return true
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ async function loadConfig() {
23
+ if (!(await fileExists(CONFIG_PATH))) return {}
24
+ const raw = await readFile(CONFIG_PATH, 'utf8')
25
+ return JSON.parse(raw)
26
+ }
27
+
28
+ async function saveConfig(config) {
29
+ await mkdir(path.dirname(CONFIG_PATH), { recursive: true })
30
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2))
31
+ }
32
+
33
+ function usage() {
34
+ console.log(`HexGrid CLI
35
+
36
+ Usage:
37
+ hexgrid login [--api-url URL] [--no-open] [--client-name NAME]
38
+ hexgrid connect [--runtime RUNTIME] [--name NAME] [--description TEXT]
39
+ hexgrid heartbeat [SESSION_ID]
40
+ hexgrid disconnect [SESSION_ID]
41
+ hexgrid me
42
+ hexgrid logout
43
+ `)
44
+ }
45
+
46
+ function parseFlag(args, name, fallback = null) {
47
+ const idx = args.indexOf(name)
48
+ if (idx === -1) return fallback
49
+ return args[idx + 1] ?? fallback
50
+ }
51
+
52
+ function hasFlag(args, name) {
53
+ return args.includes(name)
54
+ }
55
+
56
+ async function requestJson(apiUrl, endpoint, options = {}) {
57
+ const { method = 'GET', body, token } = options
58
+ const headers = { 'Content-Type': 'application/json' }
59
+ if (token) headers.Authorization = `Bearer ${token}`
60
+
61
+ const response = await fetch(`${apiUrl}${endpoint}`, {
62
+ method,
63
+ headers,
64
+ body: body ? JSON.stringify(body) : undefined,
65
+ })
66
+
67
+ let data = {}
68
+ try {
69
+ data = await response.json()
70
+ } catch {
71
+ data = {}
72
+ }
73
+
74
+ return { response, data }
75
+ }
76
+
77
+ function sleep(ms) {
78
+ return new Promise(resolve => setTimeout(resolve, ms))
79
+ }
80
+
81
+ function openBrowser(url) {
82
+ let cmd = null
83
+ let args = []
84
+
85
+ if (process.platform === 'darwin') {
86
+ cmd = 'open'
87
+ args = [url]
88
+ } else if (process.platform === 'linux') {
89
+ cmd = 'xdg-open'
90
+ args = [url]
91
+ } else {
92
+ return false
93
+ }
94
+
95
+ try {
96
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' })
97
+ child.unref()
98
+ return true
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ function runGit(args) {
105
+ const result = spawnSync('git', args, { encoding: 'utf8' })
106
+ if (result.status !== 0) return null
107
+ return result.stdout.trim()
108
+ }
109
+
110
+ function commandExists(command) {
111
+ const result = spawnSync('sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' })
112
+ return result.status === 0
113
+ }
114
+
115
+ async function detectRepoContext() {
116
+ const repoRoot = runGit(['rev-parse', '--show-toplevel']) ?? process.cwd()
117
+ const repoName = path.basename(repoRoot)
118
+ const repoUrl = runGit(['remote', 'get-url', 'origin']) ?? `local://${repoRoot}`
119
+ const repoType = await detectRepoType(repoRoot)
120
+ const tools = TOOL_CANDIDATES.filter(commandExists)
121
+
122
+ return { repoRoot, repoName, repoUrl, repoType, tools }
123
+ }
124
+
125
+ async function detectRepoType(repoRoot) {
126
+ const frontendMarkers = ['next.config.js', 'next.config.ts', 'vite.config.js', 'vite.config.ts']
127
+ const backendMarkers = ['pyproject.toml', 'requirements.txt', 'go.mod', 'Cargo.toml', 'Dockerfile']
128
+
129
+ const hasFrontend = (await Promise.all(frontendMarkers.map(marker => fileExists(path.join(repoRoot, marker))))).some(Boolean)
130
+ const hasBackend = (await Promise.all(backendMarkers.map(marker => fileExists(path.join(repoRoot, marker))))).some(Boolean)
131
+
132
+ if (hasFrontend && hasBackend) return 'fullstack'
133
+ if (hasFrontend) return 'frontend'
134
+ if (hasBackend) return 'backend'
135
+ return 'fullstack'
136
+ }
137
+
138
+ function resolveApiUrl(args, config) {
139
+ return parseFlag(args, '--api-url', null) ?? config.api_url ?? DEFAULT_API_URL
140
+ }
141
+
142
+ function resolveToken(config) {
143
+ return process.env.HEXGRID_TOKEN ?? config.access_token ?? null
144
+ }
145
+
146
+ async function commandLogin(args) {
147
+ const config = await loadConfig()
148
+ const apiUrl = resolveApiUrl(args, config)
149
+ const clientName = parseFlag(args, '--client-name', `hexgrid-cli@${os.hostname()}`)
150
+ const shouldOpen = !hasFlag(args, '--no-open')
151
+
152
+ const start = await requestJson(apiUrl, '/auth/device/start', {
153
+ method: 'POST',
154
+ body: { client_name: clientName },
155
+ })
156
+
157
+ if (!start.response.ok) {
158
+ throw new Error(start.data.error ?? `Failed to start login (${start.response.status})`)
159
+ }
160
+
161
+ const {
162
+ device_code: deviceCode,
163
+ user_code: userCode,
164
+ verification_uri: verificationUri,
165
+ verification_uri_complete: verificationUriComplete,
166
+ interval_seconds: intervalSeconds,
167
+ expires_in_seconds: expiresInSeconds,
168
+ } = start.data
169
+
170
+ console.log(`Open this URL to approve login:\n${verificationUriComplete ?? verificationUri}\n`)
171
+ console.log(`Device code: ${userCode}`)
172
+
173
+ if (shouldOpen) {
174
+ const opened = openBrowser(verificationUriComplete ?? verificationUri)
175
+ if (opened) console.log('Opened browser for approval.')
176
+ }
177
+
178
+ const startedAt = Date.now()
179
+ const deadline = startedAt + Number(expiresInSeconds ?? 600) * 1000
180
+ const intervalMs = Math.max(1000, Number(intervalSeconds ?? 3) * 1000)
181
+
182
+ while (Date.now() < deadline) {
183
+ await sleep(intervalMs)
184
+ const poll = await requestJson(apiUrl, '/auth/device/poll', {
185
+ method: 'POST',
186
+ body: { device_code: deviceCode },
187
+ })
188
+
189
+ if (poll.response.ok && poll.data.access_token) {
190
+ const next = {
191
+ ...config,
192
+ api_url: apiUrl,
193
+ access_token: poll.data.access_token,
194
+ access_token_expires_at: Math.floor(Date.now() / 1000) + Number(poll.data.expires_in_seconds ?? 0),
195
+ }
196
+
197
+ const me = await requestJson(apiUrl, '/api/cli/me', {
198
+ method: 'GET',
199
+ token: poll.data.access_token,
200
+ })
201
+ if (me.response.ok) {
202
+ next.user_id = me.data.user_id
203
+ next.email = me.data.email
204
+ }
205
+
206
+ await saveConfig(next)
207
+ console.log(`Login successful${next.email ? ` for ${next.email}` : ''}.`)
208
+ return
209
+ }
210
+
211
+ if (poll.response.ok && poll.data.status === 'pending') {
212
+ process.stdout.write('.')
213
+ continue
214
+ }
215
+
216
+ throw new Error(poll.data.error ?? `Device login failed (${poll.response.status})`)
217
+ }
218
+
219
+ throw new Error('Device login timed out. Run `hexgrid login` again.')
220
+ }
221
+
222
+ function sessionKey(repoRoot) {
223
+ return path.resolve(repoRoot)
224
+ }
225
+
226
+ async function commandConnect(args) {
227
+ const config = await loadConfig()
228
+ const apiUrl = resolveApiUrl(args, config)
229
+ const token = resolveToken(config)
230
+ if (!token) throw new Error('Not logged in. Run `hexgrid login` first.')
231
+
232
+ const runtime = parseFlag(args, '--runtime', 'cli')
233
+ const context = await detectRepoContext()
234
+ const name = parseFlag(args, '--name', `${context.repoName}-${runtime}`)
235
+ const description = parseFlag(
236
+ args,
237
+ '--description',
238
+ `${runtime} session for ${context.repoName} (${context.repoType})`,
239
+ )
240
+
241
+ const capabilities = [
242
+ `repo:${context.repoName}`,
243
+ `surface:${context.repoType}`,
244
+ `runtime:${runtime}`,
245
+ ...context.tools.map(tool => `tool:${tool}`),
246
+ ]
247
+
248
+ const connect = await requestJson(apiUrl, '/api/cli/connect', {
249
+ method: 'POST',
250
+ token,
251
+ body: {
252
+ name,
253
+ repo_url: context.repoUrl,
254
+ description,
255
+ capabilities: Array.from(new Set(capabilities)),
256
+ },
257
+ })
258
+
259
+ if (!connect.response.ok) {
260
+ throw new Error(connect.data.error ?? `Connect failed (${connect.response.status})`)
261
+ }
262
+
263
+ const sessions = config.sessions ?? {}
264
+ sessions[sessionKey(context.repoRoot)] = {
265
+ session_id: connect.data.session_id,
266
+ repo_root: context.repoRoot,
267
+ repo_url: context.repoUrl,
268
+ runtime,
269
+ name,
270
+ connected_at: Math.floor(Date.now() / 1000),
271
+ }
272
+
273
+ await saveConfig({
274
+ ...config,
275
+ api_url: apiUrl,
276
+ sessions,
277
+ last_session_id: connect.data.session_id,
278
+ })
279
+
280
+ console.log(JSON.stringify({
281
+ session_id: connect.data.session_id,
282
+ hex_id: connect.data.hex_id,
283
+ active_sessions: connect.data.active_sessions?.length ?? 0,
284
+ repo: context.repoName,
285
+ runtime,
286
+ }, null, 2))
287
+ }
288
+
289
+ async function resolveSessionId(config, args) {
290
+ const positional = args.find(arg => !arg.startsWith('-'))
291
+ if (positional) return positional
292
+
293
+ const context = await detectRepoContext()
294
+ const byRepo = config.sessions?.[sessionKey(context.repoRoot)]?.session_id
295
+ return byRepo ?? config.last_session_id ?? null
296
+ }
297
+
298
+ async function commandHeartbeat(args) {
299
+ const config = await loadConfig()
300
+ const apiUrl = resolveApiUrl(args, config)
301
+ const token = resolveToken(config)
302
+ if (!token) throw new Error('Not logged in. Run `hexgrid login` first.')
303
+
304
+ const sessionId = await resolveSessionId(config, args)
305
+ if (!sessionId) throw new Error('No session_id found. Pass one explicitly or run connect in this repo.')
306
+
307
+ const heartbeat = await requestJson(apiUrl, '/api/cli/heartbeat', {
308
+ method: 'POST',
309
+ token,
310
+ body: { session_id: sessionId },
311
+ })
312
+
313
+ if (!heartbeat.response.ok) {
314
+ throw new Error(heartbeat.data.error ?? `Heartbeat failed (${heartbeat.response.status})`)
315
+ }
316
+
317
+ console.log(JSON.stringify(heartbeat.data, null, 2))
318
+ }
319
+
320
+ async function commandDisconnect(args) {
321
+ const config = await loadConfig()
322
+ const apiUrl = resolveApiUrl(args, config)
323
+ const token = resolveToken(config)
324
+ if (!token) throw new Error('Not logged in. Run `hexgrid login` first.')
325
+
326
+ const sessionId = await resolveSessionId(config, args)
327
+ if (!sessionId) throw new Error('No session_id found. Pass one explicitly or run connect in this repo.')
328
+
329
+ const disconnect = await requestJson(apiUrl, '/api/cli/disconnect', {
330
+ method: 'POST',
331
+ token,
332
+ body: { session_id: sessionId },
333
+ })
334
+
335
+ if (!disconnect.response.ok) {
336
+ throw new Error(disconnect.data.error ?? `Disconnect failed (${disconnect.response.status})`)
337
+ }
338
+
339
+ const context = await detectRepoContext()
340
+ const sessions = { ...(config.sessions ?? {}) }
341
+ const key = sessionKey(context.repoRoot)
342
+ if (sessions[key]?.session_id === sessionId) delete sessions[key]
343
+
344
+ await saveConfig({
345
+ ...config,
346
+ sessions,
347
+ last_session_id: config.last_session_id === sessionId ? null : config.last_session_id,
348
+ })
349
+
350
+ console.log(JSON.stringify(disconnect.data, null, 2))
351
+ }
352
+
353
+ async function commandMe(args) {
354
+ const config = await loadConfig()
355
+ const apiUrl = resolveApiUrl(args, config)
356
+ const token = resolveToken(config)
357
+ if (!token) throw new Error('Not logged in. Run `hexgrid login` first.')
358
+
359
+ const me = await requestJson(apiUrl, '/api/cli/me', {
360
+ method: 'GET',
361
+ token,
362
+ })
363
+
364
+ if (!me.response.ok) {
365
+ throw new Error(me.data.error ?? `Failed to fetch profile (${me.response.status})`)
366
+ }
367
+
368
+ console.log(JSON.stringify(me.data, null, 2))
369
+ }
370
+
371
+ async function commandLogout(args) {
372
+ const config = await loadConfig()
373
+ const apiUrl = resolveApiUrl(args, config)
374
+ const token = resolveToken(config)
375
+
376
+ if (token) {
377
+ await requestJson(apiUrl, '/api/cli/logout', {
378
+ method: 'POST',
379
+ token,
380
+ })
381
+ }
382
+
383
+ const next = {
384
+ ...config,
385
+ access_token: null,
386
+ access_token_expires_at: null,
387
+ user_id: null,
388
+ email: null,
389
+ }
390
+ await saveConfig(next)
391
+ console.log('Logged out.')
392
+ }
393
+
394
+ async function main() {
395
+ const [command, ...args] = process.argv.slice(2)
396
+
397
+ if (!command || command === '-h' || command === '--help' || command === 'help') {
398
+ usage()
399
+ return
400
+ }
401
+
402
+ if (command === 'login') {
403
+ await commandLogin(args)
404
+ return
405
+ }
406
+ if (command === 'connect') {
407
+ await commandConnect(args)
408
+ return
409
+ }
410
+ if (command === 'heartbeat') {
411
+ await commandHeartbeat(args)
412
+ return
413
+ }
414
+ if (command === 'disconnect') {
415
+ await commandDisconnect(args)
416
+ return
417
+ }
418
+ if (command === 'me') {
419
+ await commandMe(args)
420
+ return
421
+ }
422
+ if (command === 'logout') {
423
+ await commandLogout(args)
424
+ return
425
+ }
426
+
427
+ usage()
428
+ process.exitCode = 1
429
+ }
430
+
431
+ main().catch((err) => {
432
+ const message = err instanceof Error ? err.message : String(err)
433
+ console.error(`Error: ${message}`)
434
+ process.exit(1)
435
+ })
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@jackpickard/hexgrid-cli",
3
+ "version": "0.0.3",
4
+ "description": "HexGrid command line client for login and repo session lifecycle",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "files": [
10
+ "bin/hexgrid.mjs",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "hexgrid",
15
+ "cli",
16
+ "agents",
17
+ "codex",
18
+ "claude",
19
+ "mcp"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/jmjpickard/Hexgrid"
24
+ },
25
+ "homepage": "https://github.com/jmjpickard/Hexgrid#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/jmjpickard/Hexgrid/issues"
28
+ },
29
+ "scripts": {
30
+ "check": "node ./bin/hexgrid.mjs --help",
31
+ "prepublishOnly": "npm run check"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "bin": {
37
+ "hexgrid": "bin/hexgrid.mjs"
38
+ }
39
+ }