@office-xyz/claude-code 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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/index.js +1226 -0
- package/onboarding.js +379 -0
- package/package.json +56 -0
package/onboarding.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Onboarding Flow for @office-xyz/claude-code
|
|
3
|
+
*
|
|
4
|
+
* Interactive terminal experience:
|
|
5
|
+
* Login → Create/Join Office → Name Agent → Auto Seat → Clock In
|
|
6
|
+
*
|
|
7
|
+
* Session is cached in ~/.office-xyz/session.json so subsequent runs skip login.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import chalk from 'chalk'
|
|
11
|
+
import { input, select, confirm } from '@inquirer/prompts'
|
|
12
|
+
import ora from 'ora'
|
|
13
|
+
import open from 'open'
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
|
|
15
|
+
import { createRequire } from 'module'
|
|
16
|
+
import path from 'path'
|
|
17
|
+
import os from 'os'
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url)
|
|
20
|
+
|
|
21
|
+
// ── Config ────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const SESSION_DIR = path.join(os.homedir(), '.office-xyz')
|
|
24
|
+
const SESSION_FILE = path.join(SESSION_DIR, 'session.json')
|
|
25
|
+
|
|
26
|
+
const CHAT_BRIDGE_URL =
|
|
27
|
+
process.env.CHAT_BRIDGE_URL ||
|
|
28
|
+
process.env.CHAT_BRIDGE_HTTP_URL ||
|
|
29
|
+
'https://chatbridge.aladdinagi.xyz'
|
|
30
|
+
|
|
31
|
+
const PKG_NAME = '@office-xyz/claude-code'
|
|
32
|
+
|
|
33
|
+
// ── Update Check ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Non-blocking version check against npm registry.
|
|
37
|
+
* Shows a one-line update notice if a newer version is available.
|
|
38
|
+
* Never throws — silently skips on any error.
|
|
39
|
+
*/
|
|
40
|
+
export async function checkForUpdate() {
|
|
41
|
+
try {
|
|
42
|
+
const pkg = require('./package.json')
|
|
43
|
+
const currentVersion = pkg.version
|
|
44
|
+
if (!currentVersion) return
|
|
45
|
+
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
const timeout = setTimeout(() => controller.abort(), 3000) // 3s max
|
|
48
|
+
|
|
49
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
})
|
|
52
|
+
clearTimeout(timeout)
|
|
53
|
+
|
|
54
|
+
if (!res.ok) return
|
|
55
|
+
const data = await res.json()
|
|
56
|
+
const latestVersion = data.version
|
|
57
|
+
|
|
58
|
+
if (!latestVersion || latestVersion === currentVersion) return
|
|
59
|
+
|
|
60
|
+
// Simple semver compare: split into parts and compare numerically
|
|
61
|
+
const current = currentVersion.split('.').map(Number)
|
|
62
|
+
const latest = latestVersion.split('.').map(Number)
|
|
63
|
+
let isNewer = false
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
if ((latest[i] || 0) > (current[i] || 0)) { isNewer = true; break }
|
|
66
|
+
if ((latest[i] || 0) < (current[i] || 0)) break
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isNewer) {
|
|
70
|
+
console.log('')
|
|
71
|
+
console.log(chalk.yellow(` ⚠ Update available: ${currentVersion} → ${latestVersion}`))
|
|
72
|
+
console.log(chalk.dim(` Run: npm update -g ${PKG_NAME}`))
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Silent fail — network issues, registry down, etc.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Banner ────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function printBanner(subtitle = 'Manage Your AI Agents') {
|
|
82
|
+
console.log('')
|
|
83
|
+
console.log(chalk.cyan(' ┌─────────────────────────────────────────┐'))
|
|
84
|
+
console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
|
|
85
|
+
console.log(chalk.cyan(' │') + chalk.bold.white(' ▓▓▓') + ' ' + chalk.cyan('│'))
|
|
86
|
+
console.log(chalk.cyan(' │') + chalk.bold.white(' ▓░░▓') + chalk.bold.white(' Virtual Office') + ' ' + chalk.cyan('│'))
|
|
87
|
+
console.log(chalk.cyan(' │') + chalk.bold.white(' ▓▓▓▓') + chalk.dim(` ${subtitle}`) + ' ' + chalk.cyan('│'))
|
|
88
|
+
console.log(chalk.cyan(' │') + chalk.bold.white(' ██') + ' ' + chalk.cyan('│'))
|
|
89
|
+
console.log(chalk.cyan(' │') + chalk.dim(' ▓▓▓▓ office.xyz') + ' ' + chalk.cyan('│'))
|
|
90
|
+
console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
|
|
91
|
+
console.log(chalk.cyan(' └─────────────────────────────────────────┘'))
|
|
92
|
+
console.log('')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function printClockInBanner({ agentHandle, model, seat, workspace }) {
|
|
96
|
+
console.log('')
|
|
97
|
+
console.log(chalk.cyan(' ┌─────────────────────────────────────────┐'))
|
|
98
|
+
console.log(chalk.cyan(' │') + chalk.green.bold(' ✓ Clocked in to Virtual Office') + ' ' + chalk.cyan('│'))
|
|
99
|
+
console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
|
|
100
|
+
console.log(chalk.cyan(' │') + chalk.dim(' Agent: ') + chalk.bold.white(agentHandle))
|
|
101
|
+
console.log(chalk.cyan(' │') + chalk.dim(' Model: ') + chalk.white(model || 'Claude Opus 4.6'))
|
|
102
|
+
if (seat) {
|
|
103
|
+
console.log(chalk.cyan(' │') + chalk.dim(' Seat: ') + chalk.white(seat))
|
|
104
|
+
}
|
|
105
|
+
console.log(chalk.cyan(' │') + chalk.dim(' Workspace: ') + chalk.white(workspace || process.cwd()))
|
|
106
|
+
console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
|
|
107
|
+
console.log(chalk.cyan(' │') + chalk.yellow(' Press Ctrl+C to clock out') + ' ' + chalk.cyan('│'))
|
|
108
|
+
console.log(chalk.cyan(' └─────────────────────────────────────────┘'))
|
|
109
|
+
console.log('')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Session Storage ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export function loadSession() {
|
|
115
|
+
try {
|
|
116
|
+
if (!existsSync(SESSION_FILE)) return null
|
|
117
|
+
const data = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'))
|
|
118
|
+
// Check expiry
|
|
119
|
+
if (data.expiresAt && new Date(data.expiresAt).getTime() < Date.now()) {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
return data
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function saveSession(session) {
|
|
129
|
+
try {
|
|
130
|
+
if (!existsSync(SESSION_DIR)) {
|
|
131
|
+
mkdirSync(SESSION_DIR, { recursive: true })
|
|
132
|
+
}
|
|
133
|
+
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8')
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn(chalk.dim(`[session] Failed to save: ${err.message}`))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function clearSession() {
|
|
140
|
+
try {
|
|
141
|
+
if (existsSync(SESSION_FILE)) {
|
|
142
|
+
writeFileSync(SESSION_FILE, '{}', 'utf-8')
|
|
143
|
+
}
|
|
144
|
+
} catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── API Helpers ───────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async function api(method, path, body = null, sessionToken = null) {
|
|
150
|
+
const url = `${CHAT_BRIDGE_URL}${path}`
|
|
151
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
152
|
+
if (sessionToken) headers['x-cli-session'] = sessionToken
|
|
153
|
+
|
|
154
|
+
const options = { method, headers }
|
|
155
|
+
if (body) options.body = JSON.stringify(body)
|
|
156
|
+
|
|
157
|
+
const res = await fetch(url, options)
|
|
158
|
+
const data = await res.json()
|
|
159
|
+
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
throw new Error(data.error || data.message || `API error ${res.status}`)
|
|
162
|
+
}
|
|
163
|
+
return data
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Browser Login Flow ────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async function browserLogin() {
|
|
169
|
+
const spinner = ora('Starting login...').start()
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const { loginUrl, pollToken } = await api('POST', '/api/cli/auth/start')
|
|
173
|
+
spinner.stop()
|
|
174
|
+
|
|
175
|
+
console.log(chalk.dim(' Opening browser for login...'))
|
|
176
|
+
await open(loginUrl)
|
|
177
|
+
console.log(chalk.dim(' Waiting for authentication...'))
|
|
178
|
+
console.log(chalk.dim(` (If browser didn't open, visit: ${loginUrl})`))
|
|
179
|
+
console.log('')
|
|
180
|
+
|
|
181
|
+
const pollSpinner = ora('Waiting for login...').start()
|
|
182
|
+
const POLL_INTERVAL = 1500
|
|
183
|
+
const POLL_TIMEOUT = 5 * 60 * 1000
|
|
184
|
+
|
|
185
|
+
const startTime = Date.now()
|
|
186
|
+
while (Date.now() - startTime < POLL_TIMEOUT) {
|
|
187
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL))
|
|
188
|
+
const result = await api('GET', `/api/cli/auth/poll?token=${pollToken}`)
|
|
189
|
+
|
|
190
|
+
if (result.status === 'complete') {
|
|
191
|
+
pollSpinner.succeed(chalk.green(`Logged in as ${result.session.email || result.session.userId}`))
|
|
192
|
+
return { session: result.session, offices: result.offices || [] }
|
|
193
|
+
}
|
|
194
|
+
if (result.status === 'expired') {
|
|
195
|
+
pollSpinner.fail('Login expired. Please try again.')
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pollSpinner.fail('Login timed out. Please try again.')
|
|
201
|
+
process.exit(1)
|
|
202
|
+
} catch (err) {
|
|
203
|
+
spinner.fail(`Login failed: ${err.message}`)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Office Selection / Creation ───────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
async function selectOrCreateOffice(offices, sessionToken) {
|
|
211
|
+
if (offices.length === 0) {
|
|
212
|
+
// No offices — must create
|
|
213
|
+
console.log(chalk.dim(' You don\'t have any offices yet. Let\'s create one!'))
|
|
214
|
+
console.log('')
|
|
215
|
+
return createOffice(sessionToken)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Show offices with "Create new" option
|
|
219
|
+
const choices = [
|
|
220
|
+
...offices.map(o => ({
|
|
221
|
+
name: `${o.domain || o.slug || o.displayName} ${chalk.dim(`${o.agentCount || 0} agents`)}`,
|
|
222
|
+
value: o.id,
|
|
223
|
+
})),
|
|
224
|
+
{
|
|
225
|
+
name: chalk.cyan('+ Create a new office'),
|
|
226
|
+
value: '__create__',
|
|
227
|
+
},
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
const choice = await select({
|
|
231
|
+
message: 'Select office:',
|
|
232
|
+
choices,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
if (choice === '__create__') {
|
|
236
|
+
return createOffice(sessionToken)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const selected = offices.find(o => o.id === choice)
|
|
240
|
+
return {
|
|
241
|
+
officeId: selected.id,
|
|
242
|
+
domain: selected.domain || selected.slug,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function createOffice(sessionToken) {
|
|
247
|
+
const name = await input({
|
|
248
|
+
message: 'Office name:',
|
|
249
|
+
validate: (v) => v.trim().length > 0 || 'Name is required',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
253
|
+
|
|
254
|
+
// Check availability
|
|
255
|
+
const spinner = ora(`Checking ${slug}.office.xyz...`).start()
|
|
256
|
+
try {
|
|
257
|
+
const check = await api('GET', `/api/cli/office/slug-check?slug=${encodeURIComponent(slug)}`)
|
|
258
|
+
if (!check.available) {
|
|
259
|
+
spinner.warn(`${slug}.office.xyz is taken`)
|
|
260
|
+
slug = await input({
|
|
261
|
+
message: 'Choose a different slug:',
|
|
262
|
+
validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v) || 'Lowercase alphanumeric + hyphens only',
|
|
263
|
+
})
|
|
264
|
+
} else {
|
|
265
|
+
spinner.succeed(`${slug}.office.xyz is available`)
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
spinner.stop()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Create
|
|
272
|
+
const createSpinner = ora('Creating office...').start()
|
|
273
|
+
try {
|
|
274
|
+
const result = await api('POST', '/api/cli/office/create', { name, slug }, sessionToken)
|
|
275
|
+
createSpinner.succeed(`Office created: ${chalk.bold(result.domain)}`)
|
|
276
|
+
return { officeId: result.officeId, domain: result.domain }
|
|
277
|
+
} catch (err) {
|
|
278
|
+
createSpinner.fail(`Failed to create office: ${err.message}`)
|
|
279
|
+
process.exit(1)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Agent Hire ────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
async function hireAgent(officeId, sessionToken) {
|
|
286
|
+
const agentName = await input({
|
|
287
|
+
message: 'Name your Claude Code agent:',
|
|
288
|
+
validate: (v) => {
|
|
289
|
+
const name = v.trim().toLowerCase()
|
|
290
|
+
if (!name) return 'Name is required'
|
|
291
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return 'Lowercase alphanumeric + hyphens only'
|
|
292
|
+
return true
|
|
293
|
+
},
|
|
294
|
+
transformer: (v) => v.toLowerCase(),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const spinner = ora('Setting up agent...').start()
|
|
298
|
+
try {
|
|
299
|
+
const result = await api('POST', '/api/cli/office/hire', {
|
|
300
|
+
officeId,
|
|
301
|
+
agentName: agentName.trim().toLowerCase(),
|
|
302
|
+
provider: 'claude-code',
|
|
303
|
+
}, sessionToken)
|
|
304
|
+
|
|
305
|
+
spinner.succeed(`Agent ready: ${chalk.bold(result.agentHandle)}${result.seat ? chalk.dim(` (seat: ${result.seat})`) : ''}`)
|
|
306
|
+
return result
|
|
307
|
+
} catch (err) {
|
|
308
|
+
spinner.fail(`Failed to create agent: ${err.message}`)
|
|
309
|
+
process.exit(1)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Main Onboarding Flow ─────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Run the interactive onboarding flow.
|
|
317
|
+
* Returns { agent, token } to pass to the connection logic.
|
|
318
|
+
*
|
|
319
|
+
* @returns {{ agent: string, token: string, seat?: string }}
|
|
320
|
+
*/
|
|
321
|
+
export async function runOnboarding() {
|
|
322
|
+
printBanner()
|
|
323
|
+
|
|
324
|
+
// Check for updates (non-blocking, runs in background)
|
|
325
|
+
checkForUpdate()
|
|
326
|
+
|
|
327
|
+
// 1. Check cached session
|
|
328
|
+
const cached = loadSession()
|
|
329
|
+
|
|
330
|
+
if (cached?.sessionToken && cached?.lastAgent?.handle && cached?.lastAgent?.connectionToken) {
|
|
331
|
+
// Quick reconnect path
|
|
332
|
+
const spinner = ora('Validating session...').start()
|
|
333
|
+
try {
|
|
334
|
+
const validation = await api('GET', '/api/cli/auth/session', null, cached.sessionToken)
|
|
335
|
+
if (validation.success) {
|
|
336
|
+
spinner.succeed(`Welcome back, ${chalk.bold(cached.email || cached.displayName || 'user')}!`)
|
|
337
|
+
console.log(chalk.dim(` Reconnecting as ${cached.lastAgent.handle}...`))
|
|
338
|
+
return {
|
|
339
|
+
agent: cached.lastAgent.handle,
|
|
340
|
+
token: cached.lastAgent.connectionToken,
|
|
341
|
+
seat: cached.lastAgent.seat,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// Session expired — fall through to login
|
|
346
|
+
}
|
|
347
|
+
spinner.stop()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 2. Browser login
|
|
351
|
+
const { session, offices } = await browserLogin()
|
|
352
|
+
|
|
353
|
+
// 3. Select or create office
|
|
354
|
+
const { officeId, domain } = await selectOrCreateOffice(offices, session.sessionToken)
|
|
355
|
+
|
|
356
|
+
// 4. Name and hire agent
|
|
357
|
+
const hired = await hireAgent(officeId, session.sessionToken)
|
|
358
|
+
|
|
359
|
+
// 5. Save session
|
|
360
|
+
saveSession({
|
|
361
|
+
userId: session.userId,
|
|
362
|
+
email: session.email,
|
|
363
|
+
displayName: session.displayName,
|
|
364
|
+
sessionToken: session.sessionToken,
|
|
365
|
+
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
366
|
+
lastOffice: domain,
|
|
367
|
+
lastAgent: {
|
|
368
|
+
handle: hired.agentHandle,
|
|
369
|
+
connectionToken: hired.connectionToken,
|
|
370
|
+
seat: hired.seat,
|
|
371
|
+
},
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
agent: hired.agentHandle,
|
|
376
|
+
token: hired.connectionToken,
|
|
377
|
+
seat: hired.seat,
|
|
378
|
+
}
|
|
379
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@office-xyz/claude-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect Claude Code to your Virtual Office — manage your AI agents from the terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vo-claude": "./index.js",
|
|
8
|
+
"claude-code": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"onboarding.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"CHANGELOG.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node index.js",
|
|
20
|
+
"dev": "node --watch index.js",
|
|
21
|
+
"prepublishOnly": "node -e \"require('fs').existsSync('LICENSE') || process.exit(1)\" && node --check index.js && node --check onboarding.js"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"chalk": "^5.3.0",
|
|
25
|
+
"ws": "^8.18.0",
|
|
26
|
+
"yargs": "^17.7.2",
|
|
27
|
+
"@inquirer/prompts": "^7.0.0",
|
|
28
|
+
"ora": "^8.0.0",
|
|
29
|
+
"open": "^10.0.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/AGIoffice/office.xyz",
|
|
37
|
+
"directory": "manager-host-sdk/local-host"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://office.xyz",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/AGIoffice/office.xyz/issues"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"virtual-office",
|
|
48
|
+
"office-xyz",
|
|
49
|
+
"ai-agent",
|
|
50
|
+
"claude-code",
|
|
51
|
+
"claude",
|
|
52
|
+
"anthropic",
|
|
53
|
+
"agent-management"
|
|
54
|
+
],
|
|
55
|
+
"license": "MIT"
|
|
56
|
+
}
|