@office-xyz/claude-code 0.1.0 → 0.1.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.
- package/CHANGELOG.md +12 -0
- package/index.js +194 -19
- package/onboarding.js +189 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@office-xyz/claude-code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.2] - 2026-02-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Integrate public repo sync into npm publish workflow
|
|
9
|
+
- Fall back to Cloudflare URL for SSE/WebSocket connections
|
|
10
|
+
|
|
11
|
+
## [0.1.1] - 2026-02-15
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Fix crash when running `npx @office-xyz/claude-code` without `--agent` flag (`argv.agent` undefined → `.split()` TypeError)
|
|
15
|
+
- `label` variable now defaults to `'local-host'` in interactive onboarding mode and updates after onboarding completes
|
|
16
|
+
|
|
5
17
|
## [0.1.0] - 2026-02-15
|
|
6
18
|
|
|
7
19
|
### Added
|
package/index.js
CHANGED
|
@@ -23,6 +23,28 @@ import { execSync } from 'child_process'
|
|
|
23
23
|
import path from 'path'
|
|
24
24
|
import os from 'os'
|
|
25
25
|
import { fileURLToPath } from 'url'
|
|
26
|
+
// Inline tool name normalizer (can't import from parent CJS package — ESM/CJS conflict)
|
|
27
|
+
// Maps raw CLI tool names to standard frontend TOOL_CONFIG keys
|
|
28
|
+
const TOOL_NAME_MAP = {
|
|
29
|
+
// Codex CLI
|
|
30
|
+
command_execution: 'Bash', file_edit: 'FileEdit', file_read: 'FileRead',
|
|
31
|
+
file_change: 'FileEdit', file_write: 'FileWrite', web_search: 'WebSearch',
|
|
32
|
+
mcp_call: 'Mcp', mcp_tool_call: 'Mcp', unknown_tool: 'default',
|
|
33
|
+
// Gemini CLI
|
|
34
|
+
shell: 'Bash', edit: 'Edit', read: 'Read', write: 'Write',
|
|
35
|
+
search_files: 'Grep', list_files: 'LS', web_fetch: 'WebFetch', google_search: 'WebSearch',
|
|
36
|
+
// Kimi / DeepSeek / Qwen CLI
|
|
37
|
+
execute_command: 'Bash', read_file: 'Read', write_file: 'Write', edit_file: 'Edit',
|
|
38
|
+
search: 'Grep', run_command: 'Bash', code_edit: 'FileEdit', code_search: 'Grep',
|
|
39
|
+
terminal: 'Bash', file_operation: 'FileEdit',
|
|
40
|
+
// General aliases
|
|
41
|
+
bash: 'Bash', grep: 'Grep', glob: 'Glob', ls: 'LS',
|
|
42
|
+
}
|
|
43
|
+
function normalizeToolName(rawName) {
|
|
44
|
+
if (!rawName || typeof rawName !== 'string') return 'default'
|
|
45
|
+
const lower = rawName.toLowerCase()
|
|
46
|
+
return TOOL_NAME_MAP[lower] || TOOL_NAME_MAP[rawName] || rawName
|
|
47
|
+
}
|
|
26
48
|
|
|
27
49
|
const __filename = fileURLToPath(import.meta.url)
|
|
28
50
|
const __dirname = path.dirname(__filename)
|
|
@@ -144,7 +166,7 @@ function shellEscapeArg(s) {
|
|
|
144
166
|
|
|
145
167
|
// ── Logging ────────────────────────────────────────────────────────────────
|
|
146
168
|
|
|
147
|
-
|
|
169
|
+
let label = argv.agent ? (argv.agent.split('.')[0] || argv.agent) : 'local-host'
|
|
148
170
|
function log(...args) {
|
|
149
171
|
console.log(chalk.dim(`[${new Date().toISOString()}][${label}]`), ...args)
|
|
150
172
|
}
|
|
@@ -224,6 +246,8 @@ if (argv.agent) {
|
|
|
224
246
|
let wsRef = null
|
|
225
247
|
let reconnectAttempts = 0
|
|
226
248
|
const MAX_RECONNECT_DELAY_MS = 30_000
|
|
249
|
+
let registryHeartbeatTimer = null
|
|
250
|
+
const REGISTRY_HEARTBEAT_INTERVAL_MS = 30_000 // Report liveness to Registry every 30s
|
|
227
251
|
const workspace = path.resolve(argv.workspace)
|
|
228
252
|
const model = argv.model || providerConfig.defaultModel
|
|
229
253
|
|
|
@@ -451,6 +475,11 @@ async function handleMessage(message) {
|
|
|
451
475
|
args.push(text)
|
|
452
476
|
|
|
453
477
|
log(chalk.blue(`Running: ${cmd} ${args.slice(0, 5).join(' ')}... [${args.length} args]`))
|
|
478
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
479
|
+
log(chalk.dim(` Auth: Claude login session (stripped inherited API key from env)`))
|
|
480
|
+
} else {
|
|
481
|
+
log(chalk.dim(` Auth: Claude login session`))
|
|
482
|
+
}
|
|
454
483
|
|
|
455
484
|
// 1. Send streaming.started
|
|
456
485
|
sendJSON({
|
|
@@ -467,9 +496,16 @@ async function handleMessage(message) {
|
|
|
467
496
|
// PATH by Node's child_process (works for npm global bins).
|
|
468
497
|
let child
|
|
469
498
|
try {
|
|
499
|
+
// Strip ANTHROPIC_API_KEY from child env so Claude Code CLI uses the
|
|
500
|
+
// user's local login session (claude login / Max subscription) instead
|
|
501
|
+
// of consuming API credits from a shared key that may have been loaded
|
|
502
|
+
// by load-aladdin-env.sh or other infra scripts in the same shell.
|
|
503
|
+
const childEnv = { ...process.env }
|
|
504
|
+
delete childEnv.ANTHROPIC_API_KEY
|
|
505
|
+
|
|
470
506
|
child = spawn(cmd, args, {
|
|
471
507
|
cwd: workspace,
|
|
472
|
-
env:
|
|
508
|
+
env: childEnv,
|
|
473
509
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
474
510
|
shell: false,
|
|
475
511
|
})
|
|
@@ -494,14 +530,16 @@ async function handleMessage(message) {
|
|
|
494
530
|
const emitToolStart = ({ toolUseId, toolName, input, timestamp }) => {
|
|
495
531
|
const normalizedId = toolUseId || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
496
532
|
const startedAt = Number.isFinite(timestamp) ? timestamp : Date.now()
|
|
497
|
-
|
|
533
|
+
// Normalize tool names for consistent frontend display across all CLI providers
|
|
534
|
+
const normalized = normalizeToolName(toolName || 'tool')
|
|
535
|
+
activeToolsById.set(normalizedId, { toolName: normalized, input, startedAt })
|
|
498
536
|
sendJSON({
|
|
499
537
|
type: 'tool_event',
|
|
500
538
|
sessionId,
|
|
501
539
|
commandId,
|
|
502
540
|
event: {
|
|
503
541
|
eventType: 'tool_start',
|
|
504
|
-
toolName:
|
|
542
|
+
toolName: normalized,
|
|
505
543
|
toolUseId: normalizedId,
|
|
506
544
|
input,
|
|
507
545
|
status: 'running',
|
|
@@ -515,7 +553,8 @@ async function handleMessage(message) {
|
|
|
515
553
|
if (finalizedToolIds.has(toolUseId) && !force) return
|
|
516
554
|
finalizedToolIds.add(toolUseId)
|
|
517
555
|
const endedAt = Number.isFinite(timestamp) ? timestamp : Date.now()
|
|
518
|
-
const
|
|
556
|
+
const rawTool = toolName || activeToolsById.get(toolUseId)?.toolName || 'tool'
|
|
557
|
+
const resolvedTool = normalizeToolName(rawTool)
|
|
519
558
|
const resolvedInput = input ?? activeToolsById.get(toolUseId)?.input
|
|
520
559
|
const status = error ? 'error' : 'completed'
|
|
521
560
|
const compactResult = compactToolValue(result)
|
|
@@ -570,6 +609,29 @@ async function handleMessage(message) {
|
|
|
570
609
|
return
|
|
571
610
|
}
|
|
572
611
|
|
|
612
|
+
// Input JSON deltas — accumulate tool input as it streams in
|
|
613
|
+
// Claude CLI sends tool input progressively: content_block_start has input: {},
|
|
614
|
+
// then input_json_delta events carry the actual JSON. Without accumulating these,
|
|
615
|
+
// tool indicators show "(no command)" because input is empty.
|
|
616
|
+
if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'input_json_delta') {
|
|
617
|
+
const partialJson = streamEvent.delta.partial_json || ''
|
|
618
|
+
if (blockIndex !== null) {
|
|
619
|
+
const toolState = activeToolsByIndex.get(blockIndex)
|
|
620
|
+
if (toolState) {
|
|
621
|
+
// Accumulate raw JSON string — we'll parse the complete object later
|
|
622
|
+
if (!toolState._inputJsonBuffer) toolState._inputJsonBuffer = ''
|
|
623
|
+
toolState._inputJsonBuffer += partialJson
|
|
624
|
+
// Try to parse accumulated JSON to update input (best-effort)
|
|
625
|
+
try {
|
|
626
|
+
toolState.input = JSON.parse(toolState._inputJsonBuffer)
|
|
627
|
+
} catch {
|
|
628
|
+
// Not complete JSON yet — that's fine, keep accumulating
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
|
|
573
635
|
// Thinking deltas
|
|
574
636
|
if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'thinking_delta') {
|
|
575
637
|
const deltaText = streamEvent.delta.thinking || streamEvent.delta.text || ''
|
|
@@ -695,12 +757,35 @@ async function handleMessage(message) {
|
|
|
695
757
|
|
|
696
758
|
// Message event with assistant content
|
|
697
759
|
if (event.type === 'message' && event.message?.role === 'assistant') {
|
|
698
|
-
// Extract text from content blocks
|
|
699
760
|
const content = event.message.content || []
|
|
700
761
|
for (const block of content) {
|
|
762
|
+
// Extract text blocks
|
|
701
763
|
if (block.type === 'text' && block.text) {
|
|
702
764
|
if (!fullText) fullText = block.text
|
|
703
765
|
}
|
|
766
|
+
// Extract complete tool_use input — the assistant message contains the
|
|
767
|
+
// fully accumulated input (unlike content_block_start which has {}).
|
|
768
|
+
// This allows us to retroactively update tool events with real input data.
|
|
769
|
+
if (block.type === 'tool_use' && block.id && block.input) {
|
|
770
|
+
const existing = activeToolsById.get(block.id)
|
|
771
|
+
if (existing && (!existing.input || Object.keys(existing.input).length === 0)) {
|
|
772
|
+
existing.input = block.input
|
|
773
|
+
// Re-emit tool_start with complete input so frontend can display details
|
|
774
|
+
sendJSON({
|
|
775
|
+
type: 'tool_event',
|
|
776
|
+
sessionId,
|
|
777
|
+
commandId,
|
|
778
|
+
event: {
|
|
779
|
+
eventType: 'tool_start',
|
|
780
|
+
toolName: existing.toolName,
|
|
781
|
+
toolUseId: block.id,
|
|
782
|
+
input: block.input,
|
|
783
|
+
status: 'running',
|
|
784
|
+
timestamp: existing.startedAt || now,
|
|
785
|
+
},
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
}
|
|
704
789
|
}
|
|
705
790
|
return
|
|
706
791
|
}
|
|
@@ -892,22 +977,44 @@ function connect() {
|
|
|
892
977
|
const ws = new WebSocket(managerUrl.href)
|
|
893
978
|
wsRef = ws
|
|
894
979
|
|
|
895
|
-
const
|
|
980
|
+
const PING_INTERVAL_MS = 10_000
|
|
981
|
+
const PONG_TIMEOUT_MS = 8_000 // must be < PING_INTERVAL_MS
|
|
896
982
|
let pingTimer = null
|
|
983
|
+
let pongTimer = null
|
|
984
|
+
let isAlive = false
|
|
985
|
+
|
|
986
|
+
const stopHeartbeat = () => {
|
|
987
|
+
if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
|
|
988
|
+
if (pongTimer) { clearTimeout(pongTimer); pongTimer = null }
|
|
989
|
+
}
|
|
897
990
|
|
|
898
991
|
ws.on('open', async () => {
|
|
899
992
|
log(chalk.green('Connected to Virtual Office'))
|
|
900
993
|
reconnectAttempts = 0
|
|
994
|
+
isAlive = true
|
|
901
995
|
|
|
902
|
-
//
|
|
996
|
+
// Heartbeat: ping + pong timeout detection (matches cloud managerHostProxy pattern)
|
|
903
997
|
pingTimer = setInterval(() => {
|
|
904
|
-
if (ws.readyState
|
|
905
|
-
|
|
998
|
+
if (ws.readyState !== WebSocket.OPEN) { stopHeartbeat(); return }
|
|
999
|
+
if (!isAlive) {
|
|
1000
|
+
log(chalk.red('Heartbeat timeout — no pong received, forcing reconnect'))
|
|
1001
|
+
stopHeartbeat()
|
|
1002
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
1003
|
+
return
|
|
906
1004
|
}
|
|
907
|
-
|
|
1005
|
+
isAlive = false
|
|
1006
|
+
try { ws.ping() } catch { /* ignore */ }
|
|
1007
|
+
}, PING_INTERVAL_MS)
|
|
1008
|
+
|
|
1009
|
+
ws.on('pong', () => { isAlive = true })
|
|
908
1010
|
|
|
909
1011
|
sendHostMeta(ws)
|
|
910
1012
|
|
|
1013
|
+
// Registry heartbeat: report liveness independently of WS ping/pong.
|
|
1014
|
+
// This lets Chat Bridge know the agent process is alive even during
|
|
1015
|
+
// brief WS reconnection windows.
|
|
1016
|
+
startRegistryHeartbeat()
|
|
1017
|
+
|
|
911
1018
|
// Build system prompt and register MCP server (first-class citizen setup)
|
|
912
1019
|
log(chalk.blue('Setting up agent identity and tools...'))
|
|
913
1020
|
if (!cachedSystemPrompt) {
|
|
@@ -950,7 +1057,9 @@ function connect() {
|
|
|
950
1057
|
const reasonStr = reason?.toString() || ''
|
|
951
1058
|
log(chalk.red(`Disconnected (${code} ${reasonStr})`))
|
|
952
1059
|
wsRef = null
|
|
953
|
-
|
|
1060
|
+
stopHeartbeat()
|
|
1061
|
+
// Keep registry heartbeat running during reconnection — it's independent
|
|
1062
|
+
// of the WS connection and tells Chat Bridge the process is still alive.
|
|
954
1063
|
|
|
955
1064
|
// Kill all active CLI processes on disconnect
|
|
956
1065
|
for (const [, entry] of activeChildren) {
|
|
@@ -983,6 +1092,53 @@ function scheduleReconnect() {
|
|
|
983
1092
|
setTimeout(connect, delay)
|
|
984
1093
|
}
|
|
985
1094
|
|
|
1095
|
+
// ── Registry Heartbeat ────────────────────────────────────────────────────
|
|
1096
|
+
// Reports agent liveness to Registry via Chat Bridge, independent of WS state.
|
|
1097
|
+
// This ensures Chat Bridge knows the agent process is alive even during
|
|
1098
|
+
// WS reconnection (up to 30s exponential backoff window).
|
|
1099
|
+
|
|
1100
|
+
function startRegistryHeartbeat() {
|
|
1101
|
+
if (registryHeartbeatTimer) return // already running
|
|
1102
|
+
const agentHandle = argv.agent
|
|
1103
|
+
if (!agentHandle) return
|
|
1104
|
+
|
|
1105
|
+
const chatBridgeHttpUrl =
|
|
1106
|
+
process.env.CHAT_BRIDGE_HTTP_URL ||
|
|
1107
|
+
process.env.CHAT_BRIDGE_URL ||
|
|
1108
|
+
process.env.CHAT_BRIDGE_BASE_URL ||
|
|
1109
|
+
'https://chatbridge.aladdinagi.xyz'
|
|
1110
|
+
|
|
1111
|
+
const sendHeartbeat = async () => {
|
|
1112
|
+
try {
|
|
1113
|
+
const url = `${chatBridgeHttpUrl}/api/${encodeURIComponent(agentHandle)}/heartbeat`
|
|
1114
|
+
const resp = await fetch(url, {
|
|
1115
|
+
method: 'POST',
|
|
1116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1117
|
+
body: JSON.stringify({ timestamp: new Date().toISOString() }),
|
|
1118
|
+
signal: AbortSignal.timeout(10000),
|
|
1119
|
+
})
|
|
1120
|
+
if (!resp.ok) {
|
|
1121
|
+
const text = await resp.text().catch(() => '')
|
|
1122
|
+
log(chalk.dim(`[heartbeat] Registry heartbeat failed: ${resp.status} ${text}`))
|
|
1123
|
+
}
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
// Silent — heartbeat failures are non-critical
|
|
1126
|
+
log(chalk.dim(`[heartbeat] ${err.message}`))
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Send immediately, then every 30s
|
|
1131
|
+
sendHeartbeat()
|
|
1132
|
+
registryHeartbeatTimer = setInterval(sendHeartbeat, REGISTRY_HEARTBEAT_INTERVAL_MS)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function stopRegistryHeartbeat() {
|
|
1136
|
+
if (registryHeartbeatTimer) {
|
|
1137
|
+
clearInterval(registryHeartbeatTimer)
|
|
1138
|
+
registryHeartbeatTimer = null
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
986
1142
|
// ── Local Device Connection (file system access for Workspace Panel) ───────
|
|
987
1143
|
// Second WebSocket to Chat Bridge /local-agent — registers as a local device
|
|
988
1144
|
// so the web UI can browse local files via Workspace Panel.
|
|
@@ -1006,8 +1162,17 @@ function connectLocalDevice() {
|
|
|
1006
1162
|
const dws = new WebSocket(deviceUrl)
|
|
1007
1163
|
deviceWsRef = dws
|
|
1008
1164
|
|
|
1165
|
+
const DEVICE_PING_INTERVAL_MS = 25_000
|
|
1166
|
+
const DEVICE_PONG_TIMEOUT_MS = 10_000
|
|
1167
|
+
let deviceIsAlive = false
|
|
1168
|
+
|
|
1169
|
+
const stopDeviceHeartbeat = () => {
|
|
1170
|
+
if (devicePingTimer) { clearInterval(devicePingTimer); devicePingTimer = null }
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1009
1173
|
dws.on('open', () => {
|
|
1010
1174
|
log(chalk.green('Local device connected'))
|
|
1175
|
+
deviceIsAlive = true
|
|
1011
1176
|
const agentHandle = argv.agent
|
|
1012
1177
|
const officeId = agentHandle.split('.').slice(1).join('.')
|
|
1013
1178
|
// Register as agent-bound device (NOT office-wide).
|
|
@@ -1029,13 +1194,21 @@ function connectLocalDevice() {
|
|
|
1029
1194
|
},
|
|
1030
1195
|
}))
|
|
1031
1196
|
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1197
|
+
// Heartbeat: ping + pong timeout detection
|
|
1198
|
+
stopDeviceHeartbeat()
|
|
1034
1199
|
devicePingTimer = setInterval(() => {
|
|
1035
|
-
if (dws.readyState
|
|
1036
|
-
|
|
1200
|
+
if (dws.readyState !== WebSocket.OPEN) { stopDeviceHeartbeat(); return }
|
|
1201
|
+
if (!deviceIsAlive) {
|
|
1202
|
+
log(chalk.red('[device] Heartbeat timeout — no pong received, forcing reconnect'))
|
|
1203
|
+
stopDeviceHeartbeat()
|
|
1204
|
+
try { dws.terminate() } catch { /* ignore */ }
|
|
1205
|
+
return
|
|
1037
1206
|
}
|
|
1038
|
-
|
|
1207
|
+
deviceIsAlive = false
|
|
1208
|
+
try { dws.ping() } catch { /* ignore */ }
|
|
1209
|
+
}, DEVICE_PING_INTERVAL_MS)
|
|
1210
|
+
|
|
1211
|
+
dws.on('pong', () => { deviceIsAlive = true })
|
|
1039
1212
|
})
|
|
1040
1213
|
|
|
1041
1214
|
dws.on('message', async (data) => {
|
|
@@ -1058,7 +1231,7 @@ function connectLocalDevice() {
|
|
|
1058
1231
|
dws.on('close', (code) => {
|
|
1059
1232
|
log(chalk.yellow(`Local device disconnected (${code})`))
|
|
1060
1233
|
deviceWsRef = null
|
|
1061
|
-
|
|
1234
|
+
stopDeviceHeartbeat()
|
|
1062
1235
|
// Reconnect after delay (only if not shutting down)
|
|
1063
1236
|
if (code !== 1000) {
|
|
1064
1237
|
deviceReconnectTimer = setTimeout(connectLocalDevice, 5000)
|
|
@@ -1139,7 +1312,8 @@ async function executeLocalTool(toolName, params) {
|
|
|
1139
1312
|
function shutdown() {
|
|
1140
1313
|
console.log('')
|
|
1141
1314
|
log(chalk.yellow('Clocking out...'))
|
|
1142
|
-
//
|
|
1315
|
+
// Stop registry heartbeat and unregister MCP server
|
|
1316
|
+
stopRegistryHeartbeat()
|
|
1143
1317
|
unregisterMcpServer()
|
|
1144
1318
|
for (const [, entry] of activeChildren) {
|
|
1145
1319
|
try { entry?.child?.kill('SIGTERM') } catch { /* ignore */ }
|
|
@@ -1186,6 +1360,7 @@ async function startup() {
|
|
|
1186
1360
|
argv.agent = result.agent
|
|
1187
1361
|
argv.token = result.token
|
|
1188
1362
|
hostId = result.agent
|
|
1363
|
+
label = result.agent.split('.')[0] || result.agent
|
|
1189
1364
|
|
|
1190
1365
|
// Rebuild manager URL with new agent/token
|
|
1191
1366
|
const newManagerUrl = new URL(argv.manager)
|
package/onboarding.js
CHANGED
|
@@ -30,6 +30,55 @@ const CHAT_BRIDGE_URL =
|
|
|
30
30
|
|
|
31
31
|
const PKG_NAME = '@office-xyz/claude-code'
|
|
32
32
|
|
|
33
|
+
// ── Pre-flight Check ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async function checkClaudeCodeCli() {
|
|
36
|
+
const { execSync } = await import('child_process')
|
|
37
|
+
|
|
38
|
+
// Check if claude is installed
|
|
39
|
+
let version = null
|
|
40
|
+
try {
|
|
41
|
+
version = execSync('claude --version', { encoding: 'utf-8', timeout: 5000 }).trim()
|
|
42
|
+
} catch {
|
|
43
|
+
console.log(chalk.red(' Claude Code CLI is not installed.'))
|
|
44
|
+
console.log('')
|
|
45
|
+
console.log(chalk.white(' Install it:'))
|
|
46
|
+
console.log(chalk.cyan(' curl -fsSL https://claude.ai/install.sh | bash'))
|
|
47
|
+
console.log('')
|
|
48
|
+
console.log(chalk.dim(' Or via npm: npm install -g @anthropic-ai/claude-code'))
|
|
49
|
+
console.log('')
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(chalk.dim(` Detected: Claude Code ${version}`))
|
|
54
|
+
|
|
55
|
+
// Check if logged in by running a quick command
|
|
56
|
+
try {
|
|
57
|
+
const result = execSync('claude -p "ping" --output-format text --max-turns 1', {
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
timeout: 15000,
|
|
60
|
+
env: { ...process.env, ANTHROPIC_API_KEY: undefined },
|
|
61
|
+
})
|
|
62
|
+
// If we get any response, auth works
|
|
63
|
+
console.log(chalk.dim(' Auth: Claude login session ✓'))
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const msg = (err.stderr || err.stdout || err.message || '').toLowerCase()
|
|
66
|
+
if (msg.includes('credit') || msg.includes('balance') || msg.includes('unauthorized') || msg.includes('auth')) {
|
|
67
|
+
console.log(chalk.red(' Claude Code is not logged in.'))
|
|
68
|
+
console.log('')
|
|
69
|
+
console.log(chalk.white(' Run this first:'))
|
|
70
|
+
console.log(chalk.cyan(' claude login'))
|
|
71
|
+
console.log('')
|
|
72
|
+
console.log(chalk.dim(' You need a Claude Pro, Max, or Team subscription.'))
|
|
73
|
+
console.log('')
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
// Other errors (timeout, etc.) — proceed anyway, might work
|
|
77
|
+
console.log(chalk.dim(' Auth: could not verify (will try anyway)'))
|
|
78
|
+
}
|
|
79
|
+
console.log('')
|
|
80
|
+
}
|
|
81
|
+
|
|
33
82
|
// ── Update Check ──────────────────────────────────────────────────────────
|
|
34
83
|
|
|
35
84
|
/**
|
|
@@ -104,6 +153,8 @@ export function printClockInBanner({ agentHandle, model, seat, workspace }) {
|
|
|
104
153
|
}
|
|
105
154
|
console.log(chalk.cyan(' │') + chalk.dim(' Workspace: ') + chalk.white(workspace || process.cwd()))
|
|
106
155
|
console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
|
|
156
|
+
console.log(chalk.cyan(' │') + chalk.dim(' Web UI: ') + chalk.underline.cyan('https://beta.office.xyz'))
|
|
157
|
+
console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
|
|
107
158
|
console.log(chalk.cyan(' │') + chalk.yellow(' Press Ctrl+C to clock out') + ' ' + chalk.cyan('│'))
|
|
108
159
|
console.log(chalk.cyan(' └─────────────────────────────────────────┘'))
|
|
109
160
|
console.log('')
|
|
@@ -280,9 +331,135 @@ async function createOffice(sessionToken) {
|
|
|
280
331
|
}
|
|
281
332
|
}
|
|
282
333
|
|
|
283
|
-
// ──
|
|
334
|
+
// ── Role Selection ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
const ROLE_CATEGORIES = [
|
|
337
|
+
{ id: 'business', icon: '💼', label: 'Business', description: 'Operations, Marketing, Sales, Support, Executive, HR' },
|
|
338
|
+
{ id: 'science', icon: '🔬', label: 'Science', description: 'Research, Data Science, Bioinformatics, Lab, Clinical' },
|
|
339
|
+
{ id: 'developer', icon: '💻', label: 'Developer', description: 'Full-Stack, Frontend, Backend, DevOps, AI Engineering' },
|
|
340
|
+
{ id: 'education', icon: '📖', label: 'Education', description: 'Learning, Tutoring, Knowledge Exploration' },
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
const ROLES = [
|
|
344
|
+
// Business
|
|
345
|
+
{ id: 'operations', category: 'business', icon: '📈', label: 'Operations' },
|
|
346
|
+
{ id: 'marketing', category: 'business', icon: '📣', label: 'Marketing' },
|
|
347
|
+
{ id: 'sales', category: 'business', icon: '🤝', label: 'Sales' },
|
|
348
|
+
{ id: 'support', category: 'business', icon: '💬', label: 'Support' },
|
|
349
|
+
{ id: 'executive', category: 'business', icon: '👔', label: 'Executive' },
|
|
350
|
+
{ id: 'hr', category: 'business', icon: '👥', label: 'HR' },
|
|
351
|
+
// Science
|
|
352
|
+
{ id: 'researcher', category: 'science', icon: '🔬', label: 'Researcher' },
|
|
353
|
+
{ id: 'data-scientist', category: 'science', icon: '📊', label: 'Data Scientist' },
|
|
354
|
+
{ id: 'bioinformatics', category: 'science', icon: '🧬', label: 'Bioinformatics' },
|
|
355
|
+
{ id: 'lab-manager', category: 'science', icon: '🧪', label: 'Lab Manager' },
|
|
356
|
+
{ id: 'clinical', category: 'science', icon: '🏥', label: 'Clinical' },
|
|
357
|
+
// Developer
|
|
358
|
+
{ id: 'fullstack', category: 'developer', icon: '🖥️', label: 'Full-Stack' },
|
|
359
|
+
{ id: 'frontend', category: 'developer', icon: '🎨', label: 'Frontend' },
|
|
360
|
+
{ id: 'backend', category: 'developer', icon: '⚙️', label: 'Backend' },
|
|
361
|
+
{ id: 'devops', category: 'developer', icon: '🔧', label: 'DevOps' },
|
|
362
|
+
{ id: 'ai-engineer', category: 'developer', icon: '🤖', label: 'AI Engineer' },
|
|
363
|
+
// Education
|
|
364
|
+
{ id: 'learner', category: 'education', icon: '📖', label: 'Learner' },
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
async function selectRole() {
|
|
368
|
+
const category = await select({
|
|
369
|
+
message: 'What does your agent do?',
|
|
370
|
+
choices: ROLE_CATEGORIES.map(c => ({
|
|
371
|
+
name: `${c.icon} ${c.label} ${chalk.dim(c.description)}`,
|
|
372
|
+
value: c.id,
|
|
373
|
+
})),
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const rolesInCategory = ROLES.filter(r => r.category === category)
|
|
377
|
+
|
|
378
|
+
const roleId = await select({
|
|
379
|
+
message: 'Select a role:',
|
|
380
|
+
choices: rolesInCategory.map(r => ({
|
|
381
|
+
name: `${r.icon} ${r.label}`,
|
|
382
|
+
value: r.id,
|
|
383
|
+
})),
|
|
384
|
+
})
|
|
284
385
|
|
|
285
|
-
|
|
386
|
+
const role = ROLES.find(r => r.id === roleId)
|
|
387
|
+
return { roleId, roleCategory: category, roleLabel: role?.label || roleId }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Agent Selection / Hire ─────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
const PROVIDER_LABELS = {
|
|
393
|
+
claude: 'Claude Code',
|
|
394
|
+
anthropic: 'Claude',
|
|
395
|
+
openai: 'Codex',
|
|
396
|
+
gemini: 'Gemini',
|
|
397
|
+
deepseek: 'DeepSeek',
|
|
398
|
+
qwen: 'Qwen',
|
|
399
|
+
kimi: 'Kimi',
|
|
400
|
+
ollama: 'Ollama',
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function selectOrHireAgent(officeId, sessionToken) {
|
|
404
|
+
// 1. Check for existing agents in this office
|
|
405
|
+
let existingAgents = []
|
|
406
|
+
try {
|
|
407
|
+
const data = await api('GET', `/api/cli/office/${encodeURIComponent(officeId)}/agents`, null, sessionToken)
|
|
408
|
+
existingAgents = data.agents || []
|
|
409
|
+
} catch {
|
|
410
|
+
// Continue — will go straight to hire
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Only show local Claude agents — cloud agents and non-Claude agents can't be clocked in from this CLI
|
|
414
|
+
const localAgents = existingAgents.filter(a =>
|
|
415
|
+
a.deploymentMode === 'local' &&
|
|
416
|
+
(a.provider === 'claude' || a.provider === 'claude-code' || a.provider === 'anthropic')
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if (localAgents.length > 0) {
|
|
420
|
+
// Show local agents + option to hire new
|
|
421
|
+
const providerLabel = (p) => PROVIDER_LABELS[p] || p || 'unknown'
|
|
422
|
+
|
|
423
|
+
const choices = [
|
|
424
|
+
...localAgents.map(a => ({
|
|
425
|
+
name: `${a.name} ${chalk.dim(`${a.role} · ${providerLabel(a.provider)} · ${a.seat || 'no seat'}`)}`,
|
|
426
|
+
value: a.id,
|
|
427
|
+
})),
|
|
428
|
+
{
|
|
429
|
+
name: chalk.cyan('+ Hire a new agent'),
|
|
430
|
+
value: '__hire__',
|
|
431
|
+
},
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
const choice = await select({
|
|
435
|
+
message: 'Select an agent to clock in:',
|
|
436
|
+
choices,
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
if (choice !== '__hire__') {
|
|
440
|
+
// Reconnect existing agent
|
|
441
|
+
const agent = localAgents.find(a => a.id === choice)
|
|
442
|
+
const spinner = ora('Reconnecting...').start()
|
|
443
|
+
try {
|
|
444
|
+
const result = await api('POST', '/api/cli/office/hire', {
|
|
445
|
+
officeId,
|
|
446
|
+
agentName: agent.name,
|
|
447
|
+
provider: 'claude-code',
|
|
448
|
+
}, sessionToken)
|
|
449
|
+
|
|
450
|
+
spinner.succeed(`Reconnected: ${chalk.bold(result.agentHandle)} ${chalk.dim(`(${agent.role})`)}${result.seat ? chalk.dim(` · seat: ${result.seat}`) : ''}`)
|
|
451
|
+
return result
|
|
452
|
+
} catch (err) {
|
|
453
|
+
spinner.fail(`Failed to reconnect: ${err.message}`)
|
|
454
|
+
process.exit(1)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 2. Hire new agent: select role
|
|
460
|
+
const { roleId, roleCategory, roleLabel } = await selectRole()
|
|
461
|
+
|
|
462
|
+
// 3. Name agent
|
|
286
463
|
const agentName = await input({
|
|
287
464
|
message: 'Name your Claude Code agent:',
|
|
288
465
|
validate: (v) => {
|
|
@@ -294,15 +471,18 @@ async function hireAgent(officeId, sessionToken) {
|
|
|
294
471
|
transformer: (v) => v.toLowerCase(),
|
|
295
472
|
})
|
|
296
473
|
|
|
474
|
+
// 4. Hire
|
|
297
475
|
const spinner = ora('Setting up agent...').start()
|
|
298
476
|
try {
|
|
299
477
|
const result = await api('POST', '/api/cli/office/hire', {
|
|
300
478
|
officeId,
|
|
301
479
|
agentName: agentName.trim().toLowerCase(),
|
|
302
480
|
provider: 'claude-code',
|
|
481
|
+
roleId,
|
|
482
|
+
roleCategory,
|
|
303
483
|
}, sessionToken)
|
|
304
484
|
|
|
305
|
-
spinner.succeed(`Agent ready: ${chalk.bold(result.agentHandle)}${result.seat ? chalk.dim(`
|
|
485
|
+
spinner.succeed(`Agent ready: ${chalk.bold(result.agentHandle)} ${chalk.dim(`(${roleLabel})`)}${result.seat ? chalk.dim(` · seat: ${result.seat}`) : ''}`)
|
|
306
486
|
return result
|
|
307
487
|
} catch (err) {
|
|
308
488
|
spinner.fail(`Failed to create agent: ${err.message}`)
|
|
@@ -324,6 +504,9 @@ export async function runOnboarding() {
|
|
|
324
504
|
// Check for updates (non-blocking, runs in background)
|
|
325
505
|
checkForUpdate()
|
|
326
506
|
|
|
507
|
+
// 0. Pre-flight: check Claude Code CLI is installed and logged in
|
|
508
|
+
await checkClaudeCodeCli()
|
|
509
|
+
|
|
327
510
|
// 1. Check cached session
|
|
328
511
|
const cached = loadSession()
|
|
329
512
|
|
|
@@ -335,6 +518,7 @@ export async function runOnboarding() {
|
|
|
335
518
|
if (validation.success) {
|
|
336
519
|
spinner.succeed(`Welcome back, ${chalk.bold(cached.email || cached.displayName || 'user')}!`)
|
|
337
520
|
console.log(chalk.dim(` Reconnecting as ${cached.lastAgent.handle}...`))
|
|
521
|
+
console.log(chalk.dim(` Web interface: ${chalk.underline.cyan('https://beta.office.xyz')}`))
|
|
338
522
|
return {
|
|
339
523
|
agent: cached.lastAgent.handle,
|
|
340
524
|
token: cached.lastAgent.connectionToken,
|
|
@@ -353,8 +537,8 @@ export async function runOnboarding() {
|
|
|
353
537
|
// 3. Select or create office
|
|
354
538
|
const { officeId, domain } = await selectOrCreateOffice(offices, session.sessionToken)
|
|
355
539
|
|
|
356
|
-
// 4.
|
|
357
|
-
const hired = await
|
|
540
|
+
// 4. Select existing agent or hire new
|
|
541
|
+
const hired = await selectOrHireAgent(officeId, session.sessionToken)
|
|
358
542
|
|
|
359
543
|
// 5. Save session
|
|
360
544
|
saveSession({
|