@gitping/cli 0.0.1

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/index.ts ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from 'commander'
3
+ import { loginCommand } from './commands/login.ts'
4
+ import { logoutCommand } from './commands/logout.ts'
5
+ import { whoamiCommand } from './commands/whoami.ts'
6
+ import { inboxCommand } from './commands/inbox.ts'
7
+ import { pingCommand } from './commands/ping.ts'
8
+ import { acceptCommand } from './commands/accept.ts'
9
+ import { ignoreCommand } from './commands/ignore.ts'
10
+ import { blockCommand } from './commands/block.ts'
11
+ import { chatCommand } from './commands/chat.ts'
12
+ import { statusCommand } from './commands/status.ts'
13
+ import { searchCommand } from './commands/search.ts'
14
+ import { leaderboardCommand } from './commands/leaderboard.ts'
15
+
16
+ const program = new Command()
17
+
18
+ program
19
+ .name('gitping')
20
+ .description('Contact any developer using their GitHub identity')
21
+ .version('0.0.1')
22
+ .action(() => {
23
+ program.help()
24
+ })
25
+
26
+ // ─── Auth ───────────────────────────────────────────────────────────────────
27
+
28
+ program
29
+ .command('login')
30
+ .description('Authenticate with GitHub (opens browser)')
31
+ .option('--json', 'output as JSON')
32
+ .action(withExitCode(loginCommand))
33
+
34
+ program
35
+ .command('logout')
36
+ .description('Clear stored credentials and config')
37
+ .option('--json', 'output as JSON')
38
+ .action(withExitCode(logoutCommand))
39
+
40
+ program
41
+ .command('whoami')
42
+ .description('Show your profile, rep score, and availability')
43
+ .option('--json', 'output as JSON')
44
+ .action(withExitCode(whoamiCommand))
45
+
46
+ // ─── Inbox ───────────────────────────────────────────────────────────────────
47
+
48
+ program
49
+ .command('inbox')
50
+ .description('List your received pings')
51
+ .option('--unread', 'show unread pings only')
52
+ .option('--watch', 'live mode via WebSocket')
53
+ .option('--json', 'output as JSON')
54
+ .action(withExitCode(inboxCommand))
55
+
56
+ // ─── Ping ────────────────────────────────────────────────────────────────────
57
+
58
+ program
59
+ .command('ping <recipient> <message>')
60
+ .description('Send a ping to a developer (e.g. gitping ping @torvalds "Hey!")')
61
+ .option('--cat <category>', 'category: job | collab | oss | feedback', 'collab')
62
+ .option('--json', 'output as JSON')
63
+ .action(withExitCode(pingCommand))
64
+
65
+ program
66
+ .command('accept <target>')
67
+ .description('Accept a ping by @username or ping-id')
68
+ .option('--json', 'output as JSON')
69
+ .action(withExitCode(acceptCommand))
70
+
71
+ program
72
+ .command('ignore <target>')
73
+ .description('Ignore a ping by @username or ping-id')
74
+ .option('--json', 'output as JSON')
75
+ .action(withExitCode(ignoreCommand))
76
+
77
+ program
78
+ .command('block <target>')
79
+ .description('Block a sender by @username or ping-id')
80
+ .option('--json', 'output as JSON')
81
+ .action(withExitCode(blockCommand))
82
+
83
+ // ─── Chat ────────────────────────────────────────────────────────────────────
84
+
85
+ program
86
+ .command('chat <recipient>')
87
+ .description('Open an interactive real-time thread with a developer')
88
+ .option('--json', 'output as JSON')
89
+ .action(withExitCode(chatCommand))
90
+
91
+ // ─── Status ──────────────────────────────────────────────────────────────────
92
+
93
+ program
94
+ .command('status')
95
+ .description('Show or update your availability status')
96
+ .option(
97
+ '--set <mode>',
98
+ 'set availability: open-to-collab | open-to-work | selective | heads-down',
99
+ )
100
+ .option('--message <msg>', 'set a custom status message')
101
+ .option('--json', 'output as JSON')
102
+ .action(withExitCode(statusCommand))
103
+
104
+ // ─── Discovery ───────────────────────────────────────────────────────────────
105
+
106
+ program
107
+ .command('search <username>')
108
+ .description('Look up a developer by GitHub username')
109
+ .option('--json', 'output as JSON')
110
+ .action(withExitCode(searchCommand))
111
+
112
+ program
113
+ .command('leaderboard')
114
+ .description('Show the most pinged developers')
115
+ .option('--rising', 'show developers with the biggest spike this week')
116
+ .option('--json', 'output as JSON')
117
+ .action(withExitCode(leaderboardCommand))
118
+
119
+ program.parseAsync(process.argv).catch((err) => {
120
+ const code = (err as { exitCode?: number }).exitCode ?? 1
121
+ process.exit(code)
122
+ })
123
+
124
+ /**
125
+ * Wraps a command handler so that thrown errors with an `exitCode` property
126
+ * cause the process to exit with the correct code.
127
+ * Per AGENTS.md: never call process.exit inside a command handler.
128
+ */
129
+ function withExitCode<T extends unknown[]>(
130
+ fn: (...args: T) => Promise<void>,
131
+ ): (...args: T) => Promise<void> {
132
+ return async (...args: T) => {
133
+ try {
134
+ await fn(...args)
135
+ } catch (err) {
136
+ const code = (err as { exitCode?: number }).exitCode
137
+ if (code !== undefined) {
138
+ const message = err instanceof Error ? err.message : String(err)
139
+ if (code !== 0 && message && message !== 'Not authenticated' && message !== 'Not found') {
140
+ // Most errors already printed by the command, but surface unexpected ones
141
+ }
142
+ process.exit(code)
143
+ }
144
+ throw err
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,195 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { Box, Text, useInput, useApp } from 'ink'
3
+ import tty from 'node:tty'
4
+ import WebSocket from 'ws'
5
+ import { getApiBaseUrl } from '../config.ts'
6
+ import { getThread, sendMessage } from '../api/threads.ts'
7
+ import type { Message, WSEvent } from '@gitping/shared'
8
+ import { WSEventType } from '@gitping/shared'
9
+
10
+ interface ChatViewProps {
11
+ threadId: string
12
+ token: string
13
+ myUserId: string
14
+ /** The tty.ReadStream opened on /dev/tty — passed in so we can destroy it on exit. */
15
+ ttyStream: tty.ReadStream
16
+ }
17
+
18
+ export default function ChatView({
19
+ threadId,
20
+ token,
21
+ myUserId,
22
+ ttyStream,
23
+ }: ChatViewProps): React.JSX.Element {
24
+ const { exit } = useApp()
25
+ const [messages, setMessages] = useState<Message[]>([])
26
+ const [input, setInput] = useState('')
27
+ const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>(
28
+ 'connecting',
29
+ )
30
+ const [error, setError] = useState<string | null>(null)
31
+ const wsRef = useRef<WebSocket | null>(null)
32
+
33
+ // sendingRef tracks an in-flight send so we can show a visual indicator without
34
+ // blocking keypresses — the useInput handler is kept synchronous to avoid
35
+ // Ink's input lock being held across an await.
36
+ const sendingRef = useRef(false)
37
+ const mountedRef = useRef(true)
38
+
39
+ // Load existing messages on mount
40
+ useEffect(() => {
41
+ getThread(threadId)
42
+ .then(({ messages: msgs }) => {
43
+ if (mountedRef.current) setMessages(msgs)
44
+ })
45
+ .catch((err) => {
46
+ if (mountedRef.current) setError(String(err))
47
+ })
48
+ }, [threadId])
49
+
50
+ // Connect WebSocket
51
+ useEffect(() => {
52
+ const baseUrl = getApiBaseUrl().replace(/^http/, 'ws')
53
+ const ws = new WebSocket(`${baseUrl}/v1/ws?token=${encodeURIComponent(token)}`)
54
+ wsRef.current = ws
55
+
56
+ ws.on('open', () => {
57
+ if (mountedRef.current) setStatus('connected')
58
+ })
59
+
60
+ ws.on('error', () => {
61
+ if (mountedRef.current) {
62
+ setStatus('error')
63
+ setError('WebSocket connection failed')
64
+ }
65
+ })
66
+
67
+ ws.on('close', () => {
68
+ if (mountedRef.current) setStatus('disconnected')
69
+ })
70
+
71
+ ws.on('message', (data) => {
72
+ try {
73
+ const event = JSON.parse(data.toString()) as WSEvent
74
+ if (event.type === WSEventType.MESSAGE_RECEIVED) {
75
+ const msg = event.payload as Message
76
+ if (msg.thread_id === threadId && mountedRef.current) {
77
+ setMessages((prev) => [...prev, msg])
78
+ }
79
+ }
80
+ } catch {
81
+ /* ignore malformed events */
82
+ }
83
+ })
84
+
85
+ return () => {
86
+ mountedRef.current = false
87
+ ws.close()
88
+ }
89
+ }, [threadId, token])
90
+
91
+ // doSend fires the network call without blocking useInput (which must stay sync).
92
+ const doSend = useCallback(
93
+ (body: string) => {
94
+ sendingRef.current = true
95
+ sendMessage(threadId, body)
96
+ .then((msg) => {
97
+ if (mountedRef.current) setMessages((prev) => [...prev, msg])
98
+ })
99
+ .catch((err) => {
100
+ if (mountedRef.current) setError(String(err))
101
+ })
102
+ .finally(() => {
103
+ sendingRef.current = false
104
+ })
105
+ },
106
+ [threadId],
107
+ )
108
+
109
+ // IMPORTANT: useInput handler MUST be synchronous.
110
+ // Ink wraps each call in reconciler.batchedUpdates() — if the callback is async,
111
+ // the batchedUpdates scope exits before state setters run and subsequent keypresses
112
+ // pile up in the event queue while the previous await is still pending, causing
113
+ // the terminal to appear completely frozen.
114
+ useInput((inputChar, key) => {
115
+ if (key.ctrl && inputChar === 'c') {
116
+ // Flag unmounted first so no in-flight async callbacks write state after this.
117
+ mountedRef.current = false
118
+ // Close the WebSocket before tearing down the UI.
119
+ wsRef.current?.close()
120
+ // exit() resolves waitUntilExit() in chat.ts, which then destroys ttyStream
121
+ // and restores the terminal. We do NOT touch ttyStream here because Ink's
122
+ // App.componentWillUnmount() calls setRawMode(false) on it — if we destroy
123
+ // it first that call would throw.
124
+ exit()
125
+ return
126
+ }
127
+
128
+ if (key.return) {
129
+ const body = input.trim()
130
+ if (!body || sendingRef.current) return
131
+ setInput('')
132
+ doSend(body)
133
+ return
134
+ }
135
+
136
+ if (key.backspace || key.delete) {
137
+ setInput((prev) => prev.slice(0, -1))
138
+ return
139
+ }
140
+
141
+ if (!key.ctrl && !key.meta && inputChar) {
142
+ setInput((prev) => prev + inputChar)
143
+ }
144
+ })
145
+
146
+ const statusColor =
147
+ status === 'connected'
148
+ ? 'green'
149
+ : status === 'error' || status === 'disconnected'
150
+ ? 'red'
151
+ : 'yellow'
152
+
153
+ const statusLabel = status === 'disconnected' ? 'disconnected' : status
154
+
155
+ return (
156
+ <Box flexDirection="column" padding={1}>
157
+ <Box borderStyle="single" borderColor="cyan" paddingX={1} marginBottom={1}>
158
+ <Text bold color="cyan">
159
+ GitPing Chat
160
+ </Text>
161
+ <Text> — thread </Text>
162
+ <Text dimColor>{threadId.slice(0, 8)}</Text>
163
+ <Text> </Text>
164
+ <Text color={statusColor}>● {statusLabel}</Text>
165
+ </Box>
166
+
167
+ <Box flexDirection="column" flexGrow={1} marginBottom={1}>
168
+ {messages.length === 0 && <Text dimColor>No messages yet. Start the conversation!</Text>}
169
+ {messages.map((msg) => (
170
+ <Box key={msg.id} marginBottom={0}>
171
+ <Text color={msg.sender_id === myUserId ? 'cyan' : 'white'} bold>
172
+ {msg.sender_id === myUserId ? 'You' : 'Them'}
173
+ </Text>
174
+ <Text dimColor> {new Date(msg.created_at).toLocaleTimeString()} </Text>
175
+ <Text>{msg.body}</Text>
176
+ </Box>
177
+ ))}
178
+ </Box>
179
+
180
+ {error && (
181
+ <Box marginBottom={1}>
182
+ <Text color="red">Error: {error}</Text>
183
+ </Box>
184
+ )}
185
+
186
+ <Box borderStyle="round" borderColor="gray" paddingX={1}>
187
+ <Text color="cyan">{'> '}</Text>
188
+ <Text>{input}</Text>
189
+ {sendingRef.current ? <Text dimColor> sending…</Text> : <Text dimColor>█</Text>}
190
+ </Box>
191
+
192
+ <Text dimColor> Press Enter to send · Ctrl+C to quit</Text>
193
+ </Box>
194
+ )
195
+ }
@@ -0,0 +1,134 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Box, Text, useApp } from 'ink'
3
+ import WebSocket from 'ws'
4
+ import { getApiBaseUrl } from '../config.ts'
5
+ import { listPings } from '../api/pings.ts'
6
+ import type { Ping, WSEvent } from '@gitping/shared'
7
+ import { WSEventType } from '@gitping/shared'
8
+
9
+ interface InboxWatchProps {
10
+ token: string
11
+ }
12
+
13
+ export default function InboxWatch({ token }: InboxWatchProps): React.JSX.Element {
14
+ const { exit } = useApp()
15
+ const [pings, setPings] = useState<Ping[]>([])
16
+ const [status, setStatus] = useState<'loading' | 'live' | 'error'>('loading')
17
+ const [error, setError] = useState<string | null>(null)
18
+ const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
19
+
20
+ // Initial load
21
+ useEffect(() => {
22
+ listPings()
23
+ .then((page) => {
24
+ setPings(page.data)
25
+ setStatus('live')
26
+ })
27
+ .catch((err) => {
28
+ setStatus('error')
29
+ setError(String(err))
30
+ })
31
+ }, [])
32
+
33
+ // WebSocket live updates
34
+ useEffect(() => {
35
+ const baseUrl = getApiBaseUrl().replace(/^http/, 'ws')
36
+ const ws = new WebSocket(`${baseUrl}/v1/ws?token=${encodeURIComponent(token)}`)
37
+
38
+ ws.on('open', () => setStatus('live'))
39
+ ws.on('error', () => {
40
+ setStatus('error')
41
+ setError('WebSocket connection failed — showing cached data')
42
+ })
43
+ ws.on('message', (data) => {
44
+ try {
45
+ const event = JSON.parse(data.toString()) as WSEvent
46
+ if (event.type === WSEventType.PING_RECEIVED) {
47
+ const newPing = event.payload as Ping
48
+ setPings((prev) => [newPing, ...prev])
49
+ setLastUpdate(new Date())
50
+ }
51
+ } catch {
52
+ /* ignore malformed events */
53
+ }
54
+ })
55
+
56
+ const handleExit = (): void => {
57
+ ws.close()
58
+ exit()
59
+ }
60
+
61
+ process.on('SIGINT', handleExit)
62
+
63
+ return () => {
64
+ ws.close()
65
+ process.off('SIGINT', handleExit)
66
+ }
67
+ }, [token, exit])
68
+
69
+ const statusColor = status === 'live' ? 'green' : status === 'error' ? 'red' : 'yellow'
70
+ const statusLabel = status === 'live' ? 'LIVE' : status === 'error' ? 'ERROR' : 'LOADING'
71
+
72
+ return (
73
+ <Box flexDirection="column" padding={1}>
74
+ <Box borderStyle="single" borderColor="cyan" paddingX={1} marginBottom={1}>
75
+ <Text bold color="cyan">
76
+ GitPing Inbox
77
+ </Text>
78
+ <Text> </Text>
79
+ <Text color={statusColor}>● {statusLabel}</Text>
80
+ <Text dimColor> last updated {lastUpdate.toLocaleTimeString()}</Text>
81
+ </Box>
82
+
83
+ {error && (
84
+ <Box marginBottom={1}>
85
+ <Text color="yellow">⚠ {error}</Text>
86
+ </Box>
87
+ )}
88
+
89
+ {pings.length === 0 && status !== 'loading' && (
90
+ <Text dimColor>No pings yet. Waiting for new pings…</Text>
91
+ )}
92
+
93
+ {status === 'loading' && <Text color="yellow">Loading…</Text>}
94
+
95
+ {pings.map((ping) => (
96
+ <Box key={ping.id} flexDirection="column" marginBottom={1} paddingLeft={1}>
97
+ <Box>
98
+ <Text bold color="cyan">
99
+ @{ping.sender_username}
100
+ </Text>
101
+ <Text dimColor> → </Text>
102
+ <Text bold>@{ping.recipient_username}</Text>
103
+ <Text> </Text>
104
+ <Text color={statusBadgeColor(ping.status)}>[{ping.status}]</Text>
105
+ <Text> </Text>
106
+ <Text color="yellow">{ping.category}</Text>
107
+ <Text dimColor> {ping.id.slice(0, 8)}</Text>
108
+ </Box>
109
+ <Box paddingLeft={2}>
110
+ <Text italic>{ping.message}</Text>
111
+ </Box>
112
+ <Box paddingLeft={2}>
113
+ <Text dimColor>{new Date(ping.created_at).toLocaleString()}</Text>
114
+ </Box>
115
+ </Box>
116
+ ))}
117
+
118
+ <Box marginTop={1}>
119
+ <Text dimColor>Ctrl+C to exit</Text>
120
+ </Box>
121
+ </Box>
122
+ )
123
+ }
124
+
125
+ function statusBadgeColor(status: string): string {
126
+ const map: Record<string, string> = {
127
+ pending: 'gray',
128
+ delivered: 'blue',
129
+ accepted: 'green',
130
+ ignored: 'yellow',
131
+ blocked: 'red',
132
+ }
133
+ return map[status] ?? 'white'
134
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext", "DOM"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleDetection": "force",
7
+ "jsx": "react-jsx",
8
+ "allowJs": true,
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noEmit": true,
13
+ "strict": true,
14
+ "skipLibCheck": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUnusedLocals": false,
17
+ "noUnusedParameters": false,
18
+ "noPropertyAccessFromIndexSignature": false
19
+ },
20
+ "include": ["src/**/*"]
21
+ }