@verbatims/cli 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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @verbatims/cli
2
+
3
+ CLI for the [Verbatims](https://verbatims.cc) quotes API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @verbatims/cli
9
+ # or use directly
10
+ npx @verbatims/cli --help
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Configure API key
17
+ verbatims config
18
+
19
+ # List quotes
20
+ verbatims quotes list --language fr --limit 10
21
+
22
+ # Browse interactively
23
+ verbatims quotes browse
24
+
25
+ # Search
26
+ verbatims search "love" --type quotes
27
+
28
+ # See all commands
29
+ verbatims --help
30
+ ```
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ await import('tsx')
3
+ await import('../src/index.ts')
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@verbatims/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for the Verbatims quotes API",
5
+ "type": "module",
6
+ "bin": {
7
+ "verbatims": "./bin/verbatims.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "dev": "tsx src/index.ts",
16
+ "typecheck": "tsc --noEmit",
17
+ "prepublishOnly": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "@verbatims/sdk": "^1.0.0",
21
+ "@clack/prompts": "^0.10.1",
22
+ "chalk": "^5.4.1",
23
+ "commander": "^13.1.0",
24
+ "tsx": "^4.19.4"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.9.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/rootasjey/verbatims-sdk.git"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "license": "MIT"
37
+ }
@@ -0,0 +1,95 @@
1
+ import type { Command } from 'commander'
2
+ import { text, isCancel, cancel } from '@clack/prompts'
3
+ import chalk from 'chalk'
4
+ import { getClient } from '../utils/client.js'
5
+ import { output, type Format } from '../utils/format.js'
6
+ import { withSpinner } from '../utils/spinner.js'
7
+ import { browse } from '../utils/browse.js'
8
+
9
+ function getFormat(program: Command): Format {
10
+ return (program.opts().format ?? 'table') as Format
11
+ }
12
+
13
+ export function registerAuthorsCommand(program: Command) {
14
+ const authors = program.command('authors').description('Manage authors')
15
+
16
+ authors
17
+ .command('list')
18
+ .description('List authors')
19
+ .option('--search <q>', 'Search authors')
20
+ .option('--limit <n>', 'Number of results', '20')
21
+ .action(async (options: Record<string, string>) => {
22
+ const client = await getClient()
23
+ const format = getFormat(program)
24
+ const { data, pagination } = await withSpinner('Fetching authors', () =>
25
+ client.authors.list({ search: options.search, limit: Number(options.limit) }),
26
+ format,
27
+ )
28
+ output(data, format)
29
+ if (pagination && format !== 'json') {
30
+ console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
31
+ }
32
+ })
33
+
34
+ authors
35
+ .command('get <id>')
36
+ .description('Get an author by ID')
37
+ .action(async (id: string) => {
38
+ const client = await getClient()
39
+ const format = getFormat(program)
40
+ const { data } = await withSpinner('Fetching author', () => client.authors.get(Number(id)), format)
41
+ output(data, format)
42
+ })
43
+
44
+ authors
45
+ .command('create')
46
+ .description('Create an author')
47
+ .option('--name <name>', 'Author name')
48
+ .option('--job <job>', 'Job title')
49
+ .option('--description <text>', 'Description')
50
+ .action(async (options: Record<string, string>) => {
51
+ const client = await getClient()
52
+ const format = getFormat(program)
53
+
54
+ const name = options.name ?? await text({ message: 'Author name', validate: (v) => !v ? 'Required' : undefined })
55
+ if (isCancel(name)) cancel('Cancelled')
56
+
57
+ const { data } = await withSpinner('Creating author', () =>
58
+ client.authors.create({ name: name as string, job: options.job ?? null, description: options.description ?? null }),
59
+ format,
60
+ )
61
+ console.log(chalk.green(' Author created'))
62
+ output(data, format)
63
+ })
64
+
65
+ authors
66
+ .command('update <id>')
67
+ .description('Update an author')
68
+ .option('--name <name>', 'Author name')
69
+ .option('--description <text>', 'Description')
70
+ .action(async (id: string, options: Record<string, string>) => {
71
+ const client = await getClient()
72
+ const format = getFormat(program)
73
+
74
+ const data_: Record<string, unknown> = {}
75
+ if (options.name) data_.name = options.name
76
+ if (options.description !== undefined) data_.description = options.description
77
+
78
+ const { data } = await withSpinner('Updating author', () => client.authors.update(Number(id), data_ as any), format)
79
+ console.log(chalk.green(' Author updated'))
80
+ output(data, format)
81
+ })
82
+
83
+ authors
84
+ .command('browse')
85
+ .description('Browse authors interactively')
86
+ .option('--search <q>', 'Search authors')
87
+ .action(async (options: Record<string, string>) => {
88
+ const client = await getClient()
89
+ const format = getFormat(program)
90
+ await browse(
91
+ (page) => client.authors.list({ page, search: options.search }),
92
+ format,
93
+ )
94
+ })
95
+ }
@@ -0,0 +1,53 @@
1
+ import type { Command } from 'commander'
2
+ import { text, isCancel, cancel } from '@clack/prompts'
3
+ import chalk from 'chalk'
4
+ import { getClient } from '../utils/client.js'
5
+ import { output, type Format } from '../utils/format.js'
6
+ import { withSpinner } from '../utils/spinner.js'
7
+
8
+ function getFormat(program: Command): Format {
9
+ return (program.opts().format ?? 'plain') as Format
10
+ }
11
+
12
+ export function registerCollectionsCommand(program: Command) {
13
+ const collections = program.command('collections').description('Manage collections')
14
+
15
+ collections
16
+ .command('create')
17
+ .description('Create a collection')
18
+ .option('--name <name>', 'Collection name')
19
+ .option('--description <text>', 'Description')
20
+ .option('--public', 'Make collection public')
21
+ .action(async (options: Record<string, string>) => {
22
+ const client = await getClient()
23
+ const format = getFormat(program)
24
+
25
+ const name = options.name ?? await text({ message: 'Collection name', validate: (v) => !v ? 'Required' : undefined })
26
+ if (isCancel(name)) cancel('Cancelled')
27
+
28
+ const { data } = await withSpinner('Creating collection', () =>
29
+ client.collections.create({ name: name as string, description: options.description ?? null, is_public: Boolean(options.public) }),
30
+ format,
31
+ )
32
+ console.log(chalk.green(' Collection created'))
33
+ output(data, format)
34
+ })
35
+
36
+ collections
37
+ .command('add <collection-id> <quote-id>')
38
+ .description('Add a quote to a collection')
39
+ .action(async (collectionId: string, quoteId: string) => {
40
+ const client = await getClient()
41
+ await withSpinner('Adding quote to collection', () => client.collections.addQuote(Number(collectionId), Number(quoteId)))
42
+ console.log(chalk.green(` Quote #${quoteId} added to collection #${collectionId}`))
43
+ })
44
+
45
+ collections
46
+ .command('remove <collection-id> <quote-id>')
47
+ .description('Remove a quote from a collection')
48
+ .action(async (collectionId: string, quoteId: string) => {
49
+ const client = await getClient()
50
+ await withSpinner('Removing quote from collection', () => client.collections.removeQuote(Number(collectionId), Number(quoteId)))
51
+ console.log(chalk.green(` Quote #${quoteId} removed from collection #${collectionId}`))
52
+ })
53
+ }
@@ -0,0 +1,62 @@
1
+ import type { Command } from 'commander'
2
+ import { intro, outro, text, isCancel, cancel } from '@clack/prompts'
3
+ import chalk from 'chalk'
4
+ import { loadConfig, saveConfig, getConfigPath } from '../utils/config.js'
5
+
6
+ export function registerConfigCommand(program: Command) {
7
+ program
8
+ .command('config')
9
+ .description('Manage configuration')
10
+ .alias('cfg')
11
+ .option('--api-key <key>', 'Set API key')
12
+ .option('--base-url <url>', 'Set base URL')
13
+ .option('--show', 'Show current config')
14
+ .action(async (options: Record<string, unknown>) => {
15
+ if (options.show) {
16
+ const config = await loadConfig()
17
+ const key = config.apiKey
18
+ ? `${config.apiKey.slice(0, 8)}...${config.apiKey.slice(-4)}`
19
+ : chalk.dim('not set')
20
+ console.log(`API key : ${key}`)
21
+ console.log(`Base URL: ${config.baseUrl ?? chalk.dim('default')}`)
22
+ console.log(`Config : ${getConfigPath()}`)
23
+ return
24
+ }
25
+
26
+ const flags: Record<string, string> = {}
27
+ if (options.apiKey) flags.apiKey = options.apiKey as string
28
+ if (options.baseUrl) flags.baseUrl = options.baseUrl as string
29
+
30
+ if (Object.keys(flags).length > 0) {
31
+ const existing = await loadConfig()
32
+ await saveConfig({ ...existing, ...flags })
33
+ console.log(chalk.green('✓ Config updated'))
34
+ return
35
+ }
36
+
37
+ intro(chalk.bold('Verbatims Config'))
38
+
39
+ const existing = await loadConfig()
40
+
41
+ const apiKey = await text({
42
+ message: 'API key',
43
+ placeholder: 'vbt_xxx...',
44
+ defaultValue: existing.apiKey ?? '',
45
+ })
46
+ if (isCancel(apiKey)) cancel('Cancelled')
47
+
48
+ const baseUrl = await text({
49
+ message: 'Base URL (optional)',
50
+ placeholder: 'https://api.verbatims.com/v1',
51
+ defaultValue: existing.baseUrl ?? '',
52
+ })
53
+ if (isCancel(baseUrl)) cancel('Cancelled')
54
+
55
+ await saveConfig({
56
+ apiKey: apiKey as string,
57
+ baseUrl: (baseUrl as string) || undefined,
58
+ })
59
+
60
+ outro(chalk.green('Config saved'))
61
+ })
62
+ }
@@ -0,0 +1,18 @@
1
+ import type { Command } from 'commander'
2
+ import { registerQuotesCommand } from './quotes.js'
3
+ import { registerAuthorsCommand } from './authors.js'
4
+ import { registerReferencesCommand } from './references.js'
5
+ import { registerTagsCommand } from './tags.js'
6
+ import { registerCollectionsCommand } from './collections.js'
7
+ import { registerSearchCommand } from './search.js'
8
+ import { registerConfigCommand } from './config.js'
9
+
10
+ export function registerCommands(program: Command) {
11
+ registerQuotesCommand(program)
12
+ registerAuthorsCommand(program)
13
+ registerReferencesCommand(program)
14
+ registerTagsCommand(program)
15
+ registerCollectionsCommand(program)
16
+ registerSearchCommand(program)
17
+ registerConfigCommand(program)
18
+ }
@@ -0,0 +1,149 @@
1
+ import type { Command } from 'commander'
2
+ import { text, isCancel, cancel, confirm } from '@clack/prompts'
3
+ import chalk from 'chalk'
4
+ import { getClient } from '../utils/client.js'
5
+ import { output, type Format } from '../utils/format.js'
6
+ import { withSpinner } from '../utils/spinner.js'
7
+ import { browse } from '../utils/browse.js'
8
+
9
+ function getFormat(program: Command): Format {
10
+ return (program.opts().format ?? 'table') as Format
11
+ }
12
+
13
+ export function registerQuotesCommand(program: Command) {
14
+ const quotes = program.command('quotes').description('Manage quotes')
15
+
16
+ quotes
17
+ .command('list')
18
+ .description('List quotes')
19
+ .option('--language <lang>', 'Filter by language')
20
+ .option('--limit <n>', 'Number of results', '20')
21
+ .option('--author <id>', 'Filter by author ID')
22
+ .option('--tag <name>', 'Filter by tag')
23
+ .option('--search <q>', 'Search in quote text')
24
+ .option('--sort-by <field>', 'Sort field')
25
+ .option('--sort-order <order>', 'Sort order (asc|desc)')
26
+ .action(async (options: Record<string, string>) => {
27
+ const client = await getClient()
28
+ const format = getFormat(program)
29
+
30
+ const { data, pagination } = await withSpinner('Fetching quotes', () =>
31
+ client.quotes.list({
32
+ language: options.language,
33
+ limit: Number(options.limit),
34
+ author_id: options.author ? Number(options.author) : undefined,
35
+ tag: options.tag,
36
+ search: options.search,
37
+ sort_by: options.sortBy,
38
+ sort_order: options.sortOrder as 'asc' | 'desc',
39
+ }),
40
+ format,
41
+ )
42
+
43
+ if (data) output(data, format)
44
+ if (pagination && format !== 'json') {
45
+ console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
46
+ }
47
+ })
48
+
49
+ quotes
50
+ .command('get <id>')
51
+ .description('Get a quote by ID')
52
+ .action(async (id: string) => {
53
+ const client = await getClient()
54
+ const format = getFormat(program)
55
+ const { data } = await withSpinner('Fetching quote', () => client.quotes.get(Number(id)), format)
56
+ output(data, format)
57
+ })
58
+
59
+ quotes
60
+ .command('create')
61
+ .description('Create a quote')
62
+ .option('--name <text>', 'Quote text')
63
+ .option('--language <lang>', 'Language')
64
+ .option('--author-id <id>', 'Author ID')
65
+ .option('--reference-id <id>', 'Reference ID')
66
+ .action(async (options: Record<string, string>) => {
67
+ const client = await getClient()
68
+ const format = getFormat(program)
69
+
70
+ const name = options.name ?? await text({ message: 'Quote text', validate: (v) => !v ? 'Required' : undefined })
71
+ if (isCancel(name)) cancel('Cancelled')
72
+
73
+ const language = options.language ?? await text({ message: 'Language', initialValue: 'fr' })
74
+ if (isCancel(language)) cancel('Cancelled')
75
+
76
+ const authorId = options.authorId ? Number(options.authorId) : undefined
77
+ const referenceId = options.referenceId ? Number(options.referenceId) : undefined
78
+
79
+ const { data } = await withSpinner('Creating quote', () =>
80
+ client.quotes.create({ content: name as string, name: name as string, language: language as string, author_id: authorId, reference_id: referenceId }),
81
+ format,
82
+ )
83
+ console.log(chalk.green(' Quote created'))
84
+ output(data, format)
85
+ })
86
+
87
+ quotes
88
+ .command('update <id>')
89
+ .description('Update a quote')
90
+ .option('--name <text>', 'Quote text')
91
+ .option('--language <lang>', 'Language')
92
+ .option('--author-id <id>', 'Author ID')
93
+ .option('--reference-id <id>', 'Reference ID')
94
+ .action(async (id: string, options: Record<string, string>) => {
95
+ const client = await getClient()
96
+ const format = getFormat(program)
97
+
98
+ const data_: Record<string, unknown> = {}
99
+ if (options.name) { data_.name = options.name; data_.content = options.name }
100
+ if (options.language) data_.language = options.language
101
+ if (options.authorId !== undefined) data_.author_id = options.authorId === 'null' ? null : Number(options.authorId)
102
+ if (options.referenceId !== undefined) data_.reference_id = options.referenceId === 'null' ? null : Number(options.referenceId)
103
+
104
+ const { data } = await withSpinner('Updating quote', () => client.quotes.update(Number(id), data_ as any), format)
105
+ console.log(chalk.green(' Quote updated'))
106
+ output(data, format)
107
+ })
108
+
109
+ quotes
110
+ .command('delete <id>')
111
+ .description('Delete a quote')
112
+ .option('--yes', 'Skip confirmation')
113
+ .action(async (id: string, options: Record<string, unknown>) => {
114
+ if (!options.yes) {
115
+ const confirmed = await confirm({ message: `Delete quote #${id}?` })
116
+ if (isCancel(confirmed) || !confirmed) cancel('Cancelled')
117
+ }
118
+
119
+ const client = await getClient()
120
+ await withSpinner('Deleting quote', () => client.quotes.delete(Number(id)))
121
+ console.log(chalk.green(` Quote #${id} deleted`))
122
+ })
123
+
124
+ quotes
125
+ .command('browse')
126
+ .description('Browse quotes interactively')
127
+ .option('--language <lang>', 'Filter by language')
128
+ .option('--author <id>', 'Filter by author ID')
129
+ .option('--tag <name>', 'Filter by tag')
130
+ .option('--search <q>', 'Search in quote text')
131
+ .option('--sort-by <field>', 'Sort field')
132
+ .option('--sort-order <order>', 'Sort order (asc|desc)')
133
+ .action(async (options: Record<string, string>) => {
134
+ const client = await getClient()
135
+ const format = getFormat(program)
136
+ await browse(
137
+ (page) => client.quotes.list({
138
+ page,
139
+ language: options.language,
140
+ author_id: options.author ? Number(options.author) : undefined,
141
+ tag: options.tag,
142
+ search: options.search,
143
+ sort_by: options.sortBy,
144
+ sort_order: options.sortOrder as 'asc' | 'desc',
145
+ }),
146
+ format,
147
+ )
148
+ })
149
+ }
@@ -0,0 +1,101 @@
1
+ import type { Command } from 'commander'
2
+ import { text, isCancel, cancel } from '@clack/prompts'
3
+ import chalk from 'chalk'
4
+ import { getClient } from '../utils/client.js'
5
+ import { output, type Format } from '../utils/format.js'
6
+ import { withSpinner } from '../utils/spinner.js'
7
+ import { browse } from '../utils/browse.js'
8
+
9
+ function getFormat(program: Command): Format {
10
+ return (program.opts().format ?? 'table') as Format
11
+ }
12
+
13
+ export function registerReferencesCommand(program: Command) {
14
+ const refs = program.command('references').description('Manage references')
15
+
16
+ refs
17
+ .command('list')
18
+ .description('List references')
19
+ .option('--type <type>', 'Filter by type')
20
+ .option('--search <q>', 'Search references')
21
+ .option('--limit <n>', 'Number of results', '20')
22
+ .action(async (options: Record<string, string>) => {
23
+ const client = await getClient()
24
+ const format = getFormat(program)
25
+ const { data, pagination } = await withSpinner('Fetching references', () =>
26
+ client.references.list({ type: options.type, search: options.search, limit: Number(options.limit) }),
27
+ format,
28
+ )
29
+ output(data, format)
30
+ if (pagination && format !== 'json') {
31
+ console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
32
+ }
33
+ })
34
+
35
+ refs
36
+ .command('get <id>')
37
+ .description('Get a reference by ID')
38
+ .action(async (id: string) => {
39
+ const client = await getClient()
40
+ const format = getFormat(program)
41
+ const { data } = await withSpinner('Fetching reference', () => client.references.get(Number(id)), format)
42
+ output(data, format)
43
+ })
44
+
45
+ refs
46
+ .command('create')
47
+ .description('Create a reference')
48
+ .option('--name <name>', 'Reference name')
49
+ .option('--type <type>', 'Primary type')
50
+ .option('--description <text>', 'Description')
51
+ .option('--language <lang>', 'Original language')
52
+ .action(async (options: Record<string, string>) => {
53
+ const client = await getClient()
54
+ const format = getFormat(program)
55
+
56
+ const name = options.name ?? await text({ message: 'Reference name', validate: (v) => !v ? 'Required' : undefined })
57
+ if (isCancel(name)) cancel('Cancelled')
58
+
59
+ const primaryType = options.type ?? await text({ message: 'Primary type' })
60
+ if (isCancel(primaryType)) cancel('Cancelled')
61
+
62
+ const { data } = await withSpinner('Creating reference', () =>
63
+ client.references.create({ name: name as string, type: primaryType as string, primary_type: primaryType as string, description: options.description ?? null, language: options.language, original_language: options.language }),
64
+ format,
65
+ )
66
+ console.log(chalk.green(' Reference created'))
67
+ output(data, format)
68
+ })
69
+
70
+ refs
71
+ .command('update <id>')
72
+ .description('Update a reference')
73
+ .option('--name <name>', 'Reference name')
74
+ .option('--description <text>', 'Description')
75
+ .action(async (id: string, options: Record<string, string>) => {
76
+ const client = await getClient()
77
+ const format = getFormat(program)
78
+
79
+ const data_: Record<string, unknown> = {}
80
+ if (options.name) { data_.name = options.name }
81
+ if (options.description !== undefined) data_.description = options.description
82
+
83
+ const { data } = await withSpinner('Updating reference', () => client.references.update(Number(id), data_ as any), format)
84
+ console.log(chalk.green(' Reference updated'))
85
+ output(data, format)
86
+ })
87
+
88
+ refs
89
+ .command('browse')
90
+ .description('Browse references interactively')
91
+ .option('--type <type>', 'Filter by type')
92
+ .option('--search <q>', 'Search references')
93
+ .action(async (options: Record<string, string>) => {
94
+ const client = await getClient()
95
+ const format = getFormat(program)
96
+ await browse(
97
+ (page) => client.references.list({ page, type: options.type, search: options.search }),
98
+ format,
99
+ )
100
+ })
101
+ }
@@ -0,0 +1,43 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { getClient } from '../utils/client.js'
4
+ import { output, type Format } from '../utils/format.js'
5
+ import { withSpinner } from '../utils/spinner.js'
6
+ import { browse } from '../utils/browse.js'
7
+
8
+ function getFormat(program: Command): Format {
9
+ return (program.opts().format ?? 'table') as Format
10
+ }
11
+
12
+ export function registerSearchCommand(program: Command) {
13
+ program
14
+ .command('search <query>')
15
+ .description('Search quotes, authors, or references')
16
+ .option('--type <type>', 'Type to search: quotes|authors|references', 'quotes')
17
+ .option('--limit <n>', 'Number of results', '20')
18
+ .action(async (query: string, options: Record<string, string>) => {
19
+ const client = await getClient()
20
+ const format = getFormat(program)
21
+ const { data, pagination } = await withSpinner('Searching', () =>
22
+ client.search.query({ q: query, type: options.type as 'quotes' | 'authors' | 'references', limit: Number(options.limit) }),
23
+ format,
24
+ )
25
+ output(data, format)
26
+ if (pagination && format !== 'json') {
27
+ console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
28
+ }
29
+ })
30
+
31
+ program
32
+ .command('search-browse <query>')
33
+ .description('Browse search results interactively')
34
+ .option('--type <type>', 'Type to search: quotes|authors|references', 'quotes')
35
+ .action(async (query: string, options: Record<string, string>) => {
36
+ const client = await getClient()
37
+ const format = getFormat(program)
38
+ await browse(
39
+ (page) => client.search.query({ q: query, type: options.type as 'quotes' | 'authors' | 'references', page }),
40
+ format,
41
+ )
42
+ })
43
+ }
@@ -0,0 +1,18 @@
1
+ import type { Command } from 'commander'
2
+ import { getClient } from '../utils/client.js'
3
+ import { output } from '../utils/format.js'
4
+ import { withSpinner } from '../utils/spinner.js'
5
+
6
+ export function registerTagsCommand(program: Command) {
7
+ program
8
+ .command('tags')
9
+ .description('Manage tags')
10
+ .command('list')
11
+ .description('List tags')
12
+ .action(async () => {
13
+ const client = await getClient()
14
+ const format = (program.opts().format ?? 'table') as 'table' | 'json' | 'plain'
15
+ const { data } = await withSpinner('Fetching tags', () => client.tags.list(), format)
16
+ output(data, format)
17
+ })
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander'
4
+ import { registerCommands } from './commands/index.js'
5
+ import { setSilent } from './utils/spinner.js'
6
+
7
+ process.on('unhandledRejection', (err) => {
8
+ console.error(err instanceof Error ? err.message : String(err))
9
+ process.exit(1)
10
+ })
11
+
12
+ const program = new Command()
13
+
14
+ program
15
+ .name('verbatims')
16
+ .description('CLI for the Verbatims quotes API')
17
+ .option('--api-key <key>', 'API key (overrides config and env)')
18
+ .option('--format <type>', 'Output format: table|json|plain', 'table')
19
+ .showHelpAfterError()
20
+ .hook('preAction', (thisCommand) => {
21
+ const opts = thisCommand.optsWithGlobals()
22
+ if (opts.format === 'json') setSilent(true)
23
+ })
24
+
25
+ registerCommands(program)
26
+
27
+ program.parse()
@@ -0,0 +1,88 @@
1
+ import { formatTable, type Format } from './format.js'
2
+ import type { PaginationMeta } from '@verbatims/sdk'
3
+
4
+ type PageFetcher = (page: number) => Promise<{
5
+ data?: unknown[]
6
+ pagination?: PaginationMeta
7
+ }>
8
+
9
+ export async function browse(fetchPage: PageFetcher, format: Format): Promise<void> {
10
+ if (!process.stdin.isTTY) {
11
+ console.error('Browse mode requires an interactive terminal.')
12
+ process.exit(1)
13
+ }
14
+
15
+ let page = 1
16
+ let hasMore = false
17
+
18
+ process.stdin.setRawMode(true)
19
+ process.stdin.resume()
20
+
21
+ const render = async () => {
22
+ const result = await fetchPage(page)
23
+ const items = result.data ?? []
24
+ const pagination = result.pagination
25
+ hasMore = pagination?.hasMore ?? false
26
+
27
+ console.clear()
28
+
29
+ if (items.length === 0) {
30
+ console.log('No results')
31
+ return false
32
+ }
33
+
34
+ const allKeys = Object.keys(items[0] as Record<string, unknown>)
35
+ const keyOrder = ['id', 'content', 'name', 'language', 'type', 'author', 'reference']
36
+ const columns = keyOrder
37
+ .filter((k) => allKeys.includes(k))
38
+ .map((k) => ({ key: k, label: k }))
39
+
40
+ const table = formatTable(items as Record<string, unknown>[], columns)
41
+ console.log(table)
42
+
43
+ if (pagination) {
44
+ console.log(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`)
45
+ }
46
+
47
+ if (hasMore || page > 1) {
48
+ const nav = []
49
+ if (page > 1) nav.push('◀ p')
50
+ if (hasMore) nav.push('n ▶')
51
+ nav.push('q quit')
52
+ console.log(`\n${nav.join(' │ ')}`)
53
+ } else {
54
+ console.log('\nq quit')
55
+ }
56
+
57
+ return true
58
+ }
59
+
60
+ await render()
61
+
62
+ const onKey = async (key: string) => {
63
+ if (key === 'q' || key === '\u001b') {
64
+ process.stdin.setRawMode(false)
65
+ process.stdin.pause()
66
+ process.stdin.removeListener('data', onData)
67
+ console.log()
68
+ return
69
+ }
70
+
71
+ if ((key === 'n' || key === ' ' || key === '\u001b[C') && hasMore) {
72
+ page++
73
+ await render()
74
+ }
75
+
76
+ if ((key === 'p' || key === '\u001b[D') && page > 1) {
77
+ page--
78
+ await render()
79
+ }
80
+ }
81
+
82
+ const onData = (buf: Buffer) => {
83
+ const key = buf.toString()
84
+ onKey(key)
85
+ }
86
+
87
+ process.stdin.on('data', onData)
88
+ }
@@ -0,0 +1,18 @@
1
+ import { VerbatimsClient } from '@verbatims/sdk'
2
+ import { loadConfig } from './config.js'
3
+
4
+ const DEFAULT_BASE_URL = 'https://verbatims.cc/api/v1'
5
+
6
+ export async function getClient(): Promise<VerbatimsClient> {
7
+ const config = await loadConfig()
8
+ const apiKey = process.env.VERBATIMS_API_KEY ?? config.apiKey
9
+
10
+ if (!apiKey) {
11
+ console.error('Missing API key. Set VERBATIMS_API_KEY env var or run `verbatims config init`.')
12
+ process.exit(1)
13
+ }
14
+
15
+ return new VerbatimsClient(apiKey, {
16
+ baseUrl: process.env.VERBATIMS_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL,
17
+ })
18
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const CONFIG_DIR = join(homedir(), '.config', 'verbatims')
7
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
8
+
9
+ export interface Config {
10
+ apiKey?: string
11
+ baseUrl?: string
12
+ }
13
+
14
+ export function getConfigPath(): string {
15
+ return CONFIG_PATH
16
+ }
17
+
18
+ export async function loadConfig(): Promise<Config> {
19
+ try {
20
+ const raw = await readFile(CONFIG_PATH, 'utf-8')
21
+ return JSON.parse(raw) as Config
22
+ } catch {
23
+ return {}
24
+ }
25
+ }
26
+
27
+ export async function saveConfig(config: Config): Promise<void> {
28
+ if (!existsSync(CONFIG_DIR)) {
29
+ await mkdir(CONFIG_DIR, { recursive: true })
30
+ }
31
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8')
32
+ }
@@ -0,0 +1,173 @@
1
+ import chalk from 'chalk'
2
+
3
+ export type Format = 'table' | 'json' | 'plain'
4
+
5
+ const stripAnsi = (s: string) => s.replace(/\x1B\[[0-?9;]*[mK]/g, '')
6
+
7
+ function padVisual(s: string, max: number): string {
8
+ const padLen = Math.max(0, max - stripAnsi(s).length)
9
+ return s + ' '.repeat(padLen)
10
+ }
11
+
12
+ function wordWrap(s: string, max: number): string[] {
13
+ if (stripAnsi(s).length <= max) return [s]
14
+ const lines: string[] = []
15
+ let current = ''
16
+ for (const word of s.split(' ')) {
17
+ const cleanWord = stripAnsi(word)
18
+ const cleanCurrent = stripAnsi(current)
19
+ if (cleanCurrent.length + cleanWord.length + (cleanCurrent.length > 0 ? 1 : 0) > max) {
20
+ if (cleanCurrent.length > 0) {
21
+ lines.push(current)
22
+ current = word
23
+ } else {
24
+ lines.push(word)
25
+ current = ''
26
+ }
27
+ } else {
28
+ current += (current ? ' ' : '') + word
29
+ }
30
+ }
31
+ if (current) lines.push(current)
32
+ return lines
33
+ }
34
+
35
+ function terminalWidth(): number {
36
+ return process.stdout.columns ?? 100
37
+ }
38
+
39
+ function formatCell(value: unknown): string {
40
+ if (value === null || value === undefined) return chalk.dim('—')
41
+ if (typeof value === 'object') {
42
+ const obj = value as Record<string, unknown>
43
+ if ('name' in obj && typeof obj.name === 'string') return obj.name
44
+ if ('views' in obj && 'likes' in obj) {
45
+ const v = obj.views ?? 0
46
+ const l = obj.likes ?? 0
47
+ return `${chalk.dim('👁')} ${v} ${chalk.dim('❤')} ${l}`
48
+ }
49
+ if (Array.isArray(value)) {
50
+ return value.map((item) => {
51
+ if (typeof item === 'object' && item && 'name' in item) return (item as { name: string }).name
52
+ return String(item)
53
+ }).join(', ') || chalk.dim('—')
54
+ }
55
+ return chalk.dim('…')
56
+ }
57
+ if (typeof value === 'boolean') return value ? chalk.green('✓') : chalk.dim('✗')
58
+ return String(value)
59
+ }
60
+
61
+ const FIXED = ['id', 'language', 'featured', 'stats']
62
+
63
+ export function formatTable<T extends Record<string, unknown>>(
64
+ items: T[],
65
+ columns: { key: keyof T; label: string }[],
66
+ ): string {
67
+ if (items.length === 0) return chalk.dim('No results')
68
+
69
+ const colSizes = columns.map((col) => {
70
+ return Math.max(
71
+ col.label.length,
72
+ ...items.map((item) => stripAnsi(formatCell(item[col.key])).length),
73
+ )
74
+ })
75
+
76
+ const total = colSizes.reduce((a, b) => a + b + 3, 0) + 1
77
+ const terminal = terminalWidth()
78
+
79
+ if (total > terminal) {
80
+ const overflow = total - terminal
81
+ const contentIdx = columns.findIndex((c) => c.key === 'content' || c.key === 'name')
82
+ if (contentIdx !== -1) {
83
+ colSizes[contentIdx] = Math.max(24, colSizes[contentIdx]! - overflow)
84
+ }
85
+ }
86
+
87
+ const secondTotal = colSizes.reduce((a, b) => a + b + 3, 0) + 1
88
+ if (secondTotal > terminal) {
89
+ const overflow2 = secondTotal - terminal
90
+ const flexIdxs = columns
91
+ .map((c, i) => ({ key: c.key as string, i }))
92
+ .filter((c) => !FIXED.includes(c.key) && c.key !== 'content' && c.key !== 'name')
93
+ const flexTotal = flexIdxs.reduce((s, { i }) => s + colSizes[i]!, 0)
94
+ if (flexTotal > 0) {
95
+ for (const { i } of flexIdxs) {
96
+ colSizes[i] = Math.max(3, colSizes[i]! - Math.round(overflow2 * colSizes[i]! / flexTotal))
97
+ }
98
+ }
99
+ }
100
+
101
+ const sep = (left: string, mid: string, right: string) =>
102
+ left + columns.map((col, i) => '─'.repeat(colSizes[i]! + 2) + (i < columns.length - 1 ? mid : '')).join('') + right
103
+
104
+ const cell = (vals: string[]) =>
105
+ '│' + vals.map((v, i) => ` ${padVisual(v, colSizes[i]!)} │`).join('')
106
+
107
+ const headerLine = cell(columns.map((col, i) => chalk.bold(padVisual(col.label, colSizes[i]!))))
108
+
109
+ const rowParts = items.map((item) => {
110
+ const cellLines = columns.map((col, i) => wordWrap(formatCell(item[col.key]), colSizes[i]!))
111
+ const maxLines = Math.max(...cellLines.map((l) => l.length), 1)
112
+ const lines: string[] = []
113
+ for (let line = 0; line < maxLines; line++) {
114
+ const vals = columns.map((_col, i) => cellLines[i]![line] ?? '')
115
+ lines.push(cell(vals))
116
+ }
117
+ return lines.join('\n')
118
+ })
119
+
120
+ const top = sep('┌', '┬', '┐')
121
+ const mid = sep('├', '┼', '┤')
122
+ const bot = sep('└', '┴', '┘')
123
+
124
+ return [top, headerLine, mid, ...rowParts, bot].join('\n')
125
+ }
126
+
127
+ export function output(data: unknown, format: Format): void {
128
+ if (format === 'json') {
129
+ console.log(JSON.stringify(data, null, 2))
130
+ return
131
+ }
132
+
133
+ if (Array.isArray(data)) {
134
+ if (data.length === 0) {
135
+ console.log(chalk.dim('No results'))
136
+ return
137
+ }
138
+
139
+ const allKeys = Object.keys(data[0] as Record<string, unknown>)
140
+ const keyOrder = ['id', 'content', 'name', 'language', 'type', 'author', 'reference']
141
+ const columns = keyOrder
142
+ .filter((k) => allKeys.includes(k))
143
+ .map((k) => ({ key: k, label: k }))
144
+
145
+ if (format === 'table') {
146
+ console.log(formatTable(data as Record<string, unknown>[], columns))
147
+ } else {
148
+ for (const item of data) {
149
+ console.log(formatItem(item as Record<string, unknown>))
150
+ console.log()
151
+ }
152
+ }
153
+ return
154
+ }
155
+
156
+ if (typeof data === 'object' && data !== null) {
157
+ if (format === 'table') {
158
+ console.log(JSON.stringify(data, null, 2))
159
+ } else {
160
+ console.log(formatItem(data as Record<string, unknown>))
161
+ }
162
+ return
163
+ }
164
+
165
+ console.log(String(data))
166
+ }
167
+
168
+ function formatItem(item: Record<string, unknown>): string {
169
+ return Object.entries(item)
170
+ .filter(([, v]) => v !== undefined && v !== null)
171
+ .map(([k, v]) => `${chalk.bold(k)}: ${formatCell(v)}`)
172
+ .join('\n')
173
+ }
@@ -0,0 +1,28 @@
1
+ import { spinner as clackSpinner } from '@clack/prompts'
2
+ import type { Format } from './format.js'
3
+
4
+ let silentMode = false
5
+
6
+ export function setSilent(silent: boolean) {
7
+ silentMode = silent
8
+ }
9
+
10
+ export function isSilent(): boolean {
11
+ return silentMode
12
+ }
13
+
14
+ export async function withSpinner<T>(label: string, fn: () => Promise<T>, format?: Format): Promise<T> {
15
+ if (format === 'json' || silentMode) {
16
+ return fn()
17
+ }
18
+ const s = clackSpinner()
19
+ s.start(label)
20
+ try {
21
+ const result = await fn()
22
+ s.stop('Done')
23
+ return result
24
+ } catch (err) {
25
+ s.stop('Error')
26
+ throw err
27
+ }
28
+ }