@raccolta/cli 0.1.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/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # Raccolta CLI
2
+
3
+ Install:
4
+
5
+ ```powershell
6
+ npm install -g @raccolta/cli
7
+ rac login --endpoint https://api.raccolta.dev/api
8
+ rac tasks
9
+ ```
10
+
11
+ Create an API key in the dashboard under Settings > API. Keys use the
12
+ `rac_` prefix and are stored locally in `~/.raccolta/config.json`.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
4
+ const green = (text) => (useColor ? `\x1b[32m${text}\x1b[0m` : text)
5
+ const bold = (text) => (useColor ? `\x1b[1m${text}\x1b[0m` : text)
6
+ const dim = (text) => (useColor ? `\x1b[2m${text}\x1b[0m` : text)
7
+
8
+ console.log('')
9
+ console.log(`${green('[ok]')} ${bold('Raccolta CLI installed')}`)
10
+ console.log('')
11
+ console.log('Next steps:')
12
+ console.log(` 1. Create an API key in ${bold('Raccolta -> Settings -> API')}`)
13
+ console.log(` 2. Run ${bold('rac login')}`)
14
+ console.log(` 3. Run ${bold('rac tasks')}`)
15
+ console.log('')
16
+ console.log(dim('Docs: run rac help'))
package/bin/rac.js ADDED
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import readline from 'node:readline/promises'
6
+ import { stdin as input, stdout as output } from 'node:process'
7
+
8
+ const VERSION = '0.1.1'
9
+ const DEFAULT_ENDPOINT = 'https://api.raccolta.dev/api'
10
+ const CONFIG_DIR = path.join(os.homedir(), '.raccolta')
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
12
+ const INSTRUCTIONS_START = '<!-- RACCOLTA CLI INSTRUCTIONS START -->'
13
+ const INSTRUCTIONS_END = '<!-- RACCOLTA CLI INSTRUCTIONS END -->'
14
+
15
+ const COMMANDS = new Set(['login', 'tasks', 'whoami', 'logout', 'config', 'help'])
16
+ const ANSI_RE = /\x1b\[[0-9;]*m/g
17
+ const USE_COLOR = Boolean(output.isTTY) && !process.env.NO_COLOR
18
+ const ANSI = {
19
+ reset: '\x1b[0m',
20
+ bold: '\x1b[1m',
21
+ dim: '\x1b[2m',
22
+ gray: '\x1b[90m',
23
+ red: '\x1b[31m',
24
+ green: '\x1b[32m',
25
+ yellow: '\x1b[33m',
26
+ blue: '\x1b[34m',
27
+ magenta: '\x1b[35m',
28
+ cyan: '\x1b[36m',
29
+ }
30
+
31
+ main().catch((error) => {
32
+ const message = error instanceof Error ? error.message : String(error)
33
+ console.error(`Error: ${message}`)
34
+ process.exitCode = 1
35
+ })
36
+
37
+ async function main() {
38
+ const parsed = parseArgs(process.argv.slice(2))
39
+ const command = parsed.command || 'help'
40
+
41
+ if (parsed.flags.version) {
42
+ console.log(`rac ${VERSION}`)
43
+ return
44
+ }
45
+
46
+ if (parsed.flags.help || command === 'help') {
47
+ printHelp()
48
+ return
49
+ }
50
+
51
+ if (!COMMANDS.has(command)) {
52
+ throw new Error(`Unknown command "${command}". Run rac help.`)
53
+ }
54
+
55
+ if (command === 'login') return login(parsed)
56
+ if (command === 'tasks') return tasks(parsed)
57
+ if (command === 'whoami') return whoami()
58
+ if (command === 'logout') return logout()
59
+ if (command === 'config') return printConfig()
60
+ }
61
+
62
+ function parseArgs(argv) {
63
+ const flags = {}
64
+ const positionals = []
65
+ let command = ''
66
+
67
+ for (let i = 0; i < argv.length; i += 1) {
68
+ const arg = argv[i]
69
+ if (!arg.startsWith('-') && !command) {
70
+ command = arg
71
+ continue
72
+ }
73
+ if (!arg.startsWith('-')) {
74
+ positionals.push(arg)
75
+ continue
76
+ }
77
+
78
+ const normalized = arg.replace(/^--?/, '')
79
+ const [rawKey, inlineValue] = normalized.split('=', 2)
80
+ const key = rawKey.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
81
+ const takesValue = ['endpoint', 'key', 'limit'].includes(key)
82
+
83
+ if (takesValue) {
84
+ flags[key] = inlineValue ?? argv[++i]
85
+ } else {
86
+ flags[key] = true
87
+ }
88
+ }
89
+
90
+ return { command, flags, positionals }
91
+ }
92
+
93
+ function printHelp() {
94
+ console.log(`${style('Raccolta CLI', 'bold')} ${VERSION}
95
+
96
+ Usage:
97
+ rac login [--endpoint https://api.raccolta.dev/api] [--key rac_...]
98
+ rac tasks [--limit 50]
99
+ rac whoami
100
+ rac logout
101
+
102
+ Install:
103
+ npm install -g @raccolta/cli
104
+
105
+ Create API keys in the dashboard under Settings > API.`)
106
+ }
107
+
108
+ async function login({ flags }) {
109
+ const existing = await readConfig().catch(() => null)
110
+ const rl = createReadline()
111
+ try {
112
+ console.log(style('Raccolta CLI Setup', 'bold'))
113
+ console.log('')
114
+
115
+ const endpointDefault = normalizeEndpoint(
116
+ flags.endpoint ||
117
+ process.env.RAC_ENDPOINT ||
118
+ process.env.RAC_API_URL ||
119
+ existing?.endpoint ||
120
+ DEFAULT_ENDPOINT,
121
+ )
122
+ const endpointInput = flags.endpoint
123
+ ? endpointDefault
124
+ : await ask(rl, `Endpoint (${endpointDefault}): `)
125
+ const endpoint = normalizeEndpoint(endpointInput || endpointDefault)
126
+
127
+ const apiKey =
128
+ flags.key ||
129
+ process.env.RAC_API_KEY ||
130
+ (await ask(rl, 'API key (from Settings > API): '))
131
+
132
+ if (!apiKey?.trim()) throw new Error('API key is required')
133
+ if (!apiKey.trim().startsWith('rac_')) throw new Error('Raccolta API keys must start with rac_')
134
+
135
+ console.log(`${style('[ok]', 'green')} Endpoint ${style(endpoint, 'dim')}`)
136
+ console.log(`${style('[ok]', 'green')} Validating API key...`)
137
+
138
+ const validation = await apiJson(endpoint, '/cli/auth/validate', apiKey.trim())
139
+ if (!validation?.ok) throw new Error('API key validation failed')
140
+
141
+ await writeConfig({
142
+ endpoint,
143
+ apiKey: apiKey.trim(),
144
+ profile: validation.profile,
145
+ workspace: validation.workspace,
146
+ updatedAt: new Date().toISOString(),
147
+ })
148
+
149
+ const profileName = validation.profile?.name || validation.profile?.email || validation.profile?.id || 'Raccolta user'
150
+ const workspaceName = validation.workspace?.name || 'Raccolta Workspace'
151
+ console.log(`${style('[ok]', 'green')} Connected profile ${profileName} to workspace ${workspaceName}`)
152
+
153
+ const shouldAppend =
154
+ flags.yes ||
155
+ (!flags.noInstructions &&
156
+ (await confirm(rl, 'Append agent instructions to CLAUDE.md and AGENTS.md in this directory?', true)))
157
+
158
+ if (shouldAppend) {
159
+ const written = await appendAgentInstructions(process.cwd(), validation.workspace)
160
+ for (const item of written) {
161
+ console.log(`${style('[ok]', 'green')} ${item.action} ${item.file}`)
162
+ }
163
+ }
164
+
165
+ console.log('')
166
+ console.log(`Next: run ${style('rac tasks', 'bold')} to see your tasks.`)
167
+ } finally {
168
+ rl.close()
169
+ }
170
+ }
171
+
172
+ async function tasks({ flags }) {
173
+ const config = await requireConfig()
174
+ const limit = parseLimit(flags.limit)
175
+ console.log(`${style('[ok]', 'green')} Fetching tasks...`)
176
+ const data = await apiJson(
177
+ config.endpoint,
178
+ `/cli/tasks?limit=${encodeURIComponent(String(limit))}`,
179
+ config.apiKey,
180
+ )
181
+
182
+ const workspaceName = data?.workspace?.name || config.workspace?.name || 'Raccolta Workspace'
183
+ const rows = Array.isArray(data?.tasks) ? data.tasks : []
184
+ console.log('')
185
+ console.log(style(`${workspaceName} Tasks`, 'bold'))
186
+ console.log('')
187
+
188
+ if (rows.length === 0) {
189
+ console.log(style('No tasks found.', 'dim'))
190
+ return
191
+ }
192
+
193
+ printTable(
194
+ rows.map((task) => ({
195
+ Code: task.code || '',
196
+ Title: task.title || '',
197
+ Status: task.status || '',
198
+ Priority: formatPriority(task.priority),
199
+ Agent: task.agent || 'None',
200
+ })),
201
+ )
202
+ console.log('')
203
+ console.log(style(`${rows.length} ${rows.length === 1 ? 'task' : 'tasks'}`, 'dim'))
204
+ }
205
+
206
+ async function whoami() {
207
+ const config = await requireConfig()
208
+ const validation = await apiJson(config.endpoint, '/cli/auth/validate', config.apiKey)
209
+ const profileName = validation.profile?.name || validation.profile?.email || validation.profile?.id || 'Raccolta user'
210
+ const workspaceName = validation.workspace?.name || 'Raccolta Workspace'
211
+ console.log(`Profile: ${profileName}`)
212
+ console.log(`Workspace: ${workspaceName}`)
213
+ console.log(`Endpoint: ${config.endpoint}`)
214
+ }
215
+
216
+ async function logout() {
217
+ await fs.rm(CONFIG_FILE, { force: true })
218
+ console.log('Removed local Raccolta CLI credentials.')
219
+ }
220
+
221
+ async function printConfig() {
222
+ const config = await readConfig().catch(() => null)
223
+ console.log(`Config file: ${CONFIG_FILE}`)
224
+ if (!config) {
225
+ console.log('Status: not logged in')
226
+ return
227
+ }
228
+ console.log(`Endpoint: ${config.endpoint}`)
229
+ console.log(`Workspace: ${config.workspace?.name || config.workspace?.id || 'Unknown'}`)
230
+ console.log(`Profile: ${config.profile?.name || config.profile?.email || config.profile?.id || 'Unknown'}`)
231
+ }
232
+
233
+ async function apiJson(endpoint, route, apiKey) {
234
+ const response = await fetch(`${endpoint}${route}`, {
235
+ method: 'GET',
236
+ headers: {
237
+ Accept: 'application/json',
238
+ Authorization: `Bearer ${apiKey}`,
239
+ },
240
+ signal: AbortSignal.timeout(20000),
241
+ })
242
+ const text = await response.text()
243
+ let payload = null
244
+ if (text) {
245
+ try {
246
+ payload = JSON.parse(text)
247
+ } catch {
248
+ payload = { error: text.slice(0, 300) }
249
+ }
250
+ }
251
+ if (!response.ok) {
252
+ if (response.status === 401 && route.startsWith('/cli/')) {
253
+ throw new Error('API key rejected. Create a fresh key in Raccolta Settings > API, then run rac login again. If the fresh key is rejected too, deploy the latest Beast backend.')
254
+ }
255
+ throw new Error(payload?.error || `Request failed with ${response.status}`)
256
+ }
257
+ return payload
258
+ }
259
+
260
+ function normalizeEndpoint(value) {
261
+ let endpoint = String(value || DEFAULT_ENDPOINT).trim()
262
+ if (!endpoint) endpoint = DEFAULT_ENDPOINT
263
+ if (!/^https?:\/\//i.test(endpoint)) endpoint = `https://${endpoint}`
264
+ endpoint = endpoint.replace(/\/+$/, '')
265
+ if (!endpoint.endsWith('/api')) endpoint = `${endpoint}/api`
266
+ return endpoint
267
+ }
268
+
269
+ function parseLimit(value) {
270
+ if (value == null || value === '') return 50
271
+ const parsed = Number.parseInt(value, 10)
272
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new Error('limit must be a positive number')
273
+ return Math.min(parsed, 200)
274
+ }
275
+
276
+ function formatPriority(value) {
277
+ const text = String(value || '').trim().toLowerCase()
278
+ if (!text || text === 'none') return 'None'
279
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`
280
+ }
281
+
282
+ function printTable(rows) {
283
+ const headers = Object.keys(rows[0] || {})
284
+ const widths = tableWidths(headers, rows)
285
+
286
+ console.log(formatRow(headers, headers, widths, true))
287
+ console.log(style(widths.map((width) => '-'.repeat(width)).join(' '), 'gray'))
288
+ for (const row of rows) {
289
+ console.log(formatRow(headers.map((header) => row[header] ?? ''), headers, widths))
290
+ }
291
+ }
292
+
293
+ function tableWidths(headers, rows) {
294
+ const terminalWidth = Math.max(72, Math.min(Number(output.columns || 100), 140))
295
+ const gapWidth = Math.max(0, headers.length - 1) * 2
296
+ const fixedHeaders = headers.filter((header) => header !== 'Title')
297
+ const fixedCaps = {
298
+ Code: 10,
299
+ Status: 14,
300
+ Priority: 10,
301
+ Agent: 14,
302
+ }
303
+ const fixedWidths = Object.fromEntries(
304
+ fixedHeaders.map((header) => {
305
+ const max = Math.max(
306
+ visibleWidth(header),
307
+ ...rows.map((row) => visibleWidth(String(row[header] ?? ''))),
308
+ )
309
+ return [header, Math.min(max, fixedCaps[header] || max)]
310
+ }),
311
+ )
312
+ const fixedTotal = Object.values(fixedWidths).reduce((sum, width) => sum + width, 0)
313
+ const titleMax = Math.max(24, terminalWidth - fixedTotal - gapWidth)
314
+ const titleContent = Math.max(
315
+ visibleWidth('Title'),
316
+ ...rows.map((row) => visibleWidth(String(row.Title ?? ''))),
317
+ )
318
+ const titleWidth = Math.min(titleContent, titleMax)
319
+
320
+ return headers.map((header) => (header === 'Title' ? titleWidth : fixedWidths[header] || visibleWidth(header)))
321
+ }
322
+
323
+ function formatRow(values, headers, widths, isHeader = false) {
324
+ return values
325
+ .map((value, index) => {
326
+ const header = headers[index]
327
+ const width = widths[index]
328
+ const text = truncateVisible(String(value), width)
329
+ const padded = padVisible(text, width)
330
+ return isHeader ? style(padded, 'dim') : styleTableCell(header, padded, value)
331
+ })
332
+ .join(' ')
333
+ }
334
+
335
+ function styleTableCell(header, padded, rawValue) {
336
+ const value = String(rawValue || '').trim().toLowerCase()
337
+ if (header === 'Status') {
338
+ if (value === 'shipped') return style(padded, 'green')
339
+ if (value === 'in progress') return style(padded, 'yellow')
340
+ if (value === 'todo') return style(padded, 'cyan')
341
+ if (value === 'declined' || value === 'archive' || value === 'archived') return style(padded, 'gray')
342
+ if (value === 'requests') return style(padded, 'blue')
343
+ }
344
+ if (header === 'Priority') {
345
+ if (value === 'urgent') return style(padded, 'red')
346
+ if (value === 'high') return style(padded, 'yellow')
347
+ if (value === 'medium') return style(padded, 'blue')
348
+ if (value === 'low') return style(padded, 'green')
349
+ if (value === 'none') return style(padded, 'dim')
350
+ }
351
+ if (header === 'Agent' && value === 'none') return style(padded, 'dim')
352
+ return padded
353
+ }
354
+
355
+ function style(text, color) {
356
+ if (!USE_COLOR || !ANSI[color]) return text
357
+ return `${ANSI[color]}${text}${ANSI.reset}`
358
+ }
359
+
360
+ function stripAnsi(value) {
361
+ return String(value ?? '').replace(ANSI_RE, '')
362
+ }
363
+
364
+ function visibleWidth(value) {
365
+ let width = 0
366
+ for (const char of Array.from(stripAnsi(value))) {
367
+ width += charWidth(char)
368
+ }
369
+ return width
370
+ }
371
+
372
+ function padVisible(value, width) {
373
+ return `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`
374
+ }
375
+
376
+ function truncateVisible(value, width) {
377
+ const text = stripAnsi(value)
378
+ if (visibleWidth(text) <= width) return text
379
+ if (width <= 1) return Array.from(text).slice(0, width).join('')
380
+
381
+ let next = ''
382
+ let used = 0
383
+ const limit = width - 1
384
+ for (const char of Array.from(text)) {
385
+ const charDisplayWidth = charWidth(char)
386
+ if (used + charDisplayWidth > limit) break
387
+ next += char
388
+ used += charDisplayWidth
389
+ }
390
+ return `${next}.`
391
+ }
392
+
393
+ function charWidth(char) {
394
+ const code = char.codePointAt(0)
395
+ if (code == null) return 0
396
+ if (
397
+ (code >= 0x0300 && code <= 0x036f) ||
398
+ (code >= 0x1ab0 && code <= 0x1aff) ||
399
+ (code >= 0x1dc0 && code <= 0x1dff) ||
400
+ (code >= 0x20d0 && code <= 0x20ff) ||
401
+ (code >= 0xfe20 && code <= 0xfe2f)
402
+ ) {
403
+ return 0
404
+ }
405
+ if (
406
+ (code >= 0x1100 && code <= 0x115f) ||
407
+ code === 0x2329 ||
408
+ code === 0x232a ||
409
+ (code >= 0x2e80 && code <= 0xa4cf) ||
410
+ (code >= 0xac00 && code <= 0xd7a3) ||
411
+ (code >= 0xf900 && code <= 0xfaff) ||
412
+ (code >= 0xfe10 && code <= 0xfe19) ||
413
+ (code >= 0xfe30 && code <= 0xfe6f) ||
414
+ (code >= 0xff00 && code <= 0xff60) ||
415
+ (code >= 0xffe0 && code <= 0xffe6)
416
+ ) {
417
+ return 2
418
+ }
419
+ return 1
420
+ }
421
+
422
+ async function readConfig() {
423
+ const text = await fs.readFile(CONFIG_FILE, 'utf8')
424
+ return JSON.parse(text)
425
+ }
426
+
427
+ async function requireConfig() {
428
+ const config = await readConfig().catch(() => null)
429
+ if (!config?.endpoint || !config?.apiKey) {
430
+ throw new Error('Not logged in. Run rac login first.')
431
+ }
432
+ return config
433
+ }
434
+
435
+ async function writeConfig(config) {
436
+ await fs.mkdir(CONFIG_DIR, { recursive: true })
437
+ await fs.writeFile(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
438
+ await fs.chmod(CONFIG_FILE, 0o600).catch(() => {})
439
+ }
440
+
441
+ function createReadline() {
442
+ return readline.createInterface({ input, output })
443
+ }
444
+
445
+ async function ask(rl, question) {
446
+ return (await rl.question(question)).trim()
447
+ }
448
+
449
+ async function confirm(rl, question, defaultYes = true) {
450
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] '
451
+ const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase()
452
+ if (!answer) return defaultYes
453
+ return ['y', 'yes', 'j', 'ja'].includes(answer)
454
+ }
455
+
456
+ async function appendAgentInstructions(directory, workspace = {}) {
457
+ const workspaceName = workspace?.name || 'Raccolta Workspace'
458
+ const block = `${INSTRUCTIONS_START}
459
+ # Raccolta CLI
460
+
461
+ This project is connected to the Raccolta workspace "${workspaceName}".
462
+
463
+ - Use \`rac tasks\` to list current Raccolta tasks/cards.
464
+ - Use task codes like \`RAC-12\` when referencing work from the CLI.
465
+ - Do not print or commit Raccolta API keys. Local credentials are stored in \`~/.raccolta/config.json\`.
466
+ ${INSTRUCTIONS_END}
467
+ `
468
+
469
+ const results = []
470
+ for (const file of ['CLAUDE.md', 'AGENTS.md']) {
471
+ const filePath = path.join(directory, file)
472
+ const existing = await fs.readFile(filePath, 'utf8').catch(() => '')
473
+ if (existing.includes(INSTRUCTIONS_START)) {
474
+ results.push({ file, action: 'Instructions already present in' })
475
+ continue
476
+ }
477
+
478
+ const next = existing.trimEnd() ? `${existing.trimEnd()}\n\n${block}` : `${block}`
479
+ await fs.writeFile(filePath, next, 'utf8')
480
+ results.push({ file, action: existing ? 'Updated' : 'Created' })
481
+ }
482
+ return results
483
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@raccolta/cli",
3
+ "version": "0.1.1",
4
+ "description": "Raccolta command line integration",
5
+ "type": "module",
6
+ "scripts": {
7
+ "postinstall": "node ./bin/postinstall.js"
8
+ },
9
+ "bin": {
10
+ "rac": "bin/rac.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "license": "UNLICENSED"
23
+ }