@chatlane/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,15 @@
1
+ # `@chatlane/cli`
2
+
3
+ Install globally:
4
+
5
+ ```bash
6
+ npm i -g @chatlane/cli
7
+ ```
8
+
9
+ Commands:
10
+
11
+ - `chatlane --help`
12
+ - `chatlane login`
13
+ - `chatlane logout`
14
+ - `chatlane whoami`
15
+ - `chatlane ask "<question>"`
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@chatlane/cli",
3
+ "version": "0.1.0",
4
+ "description": "Chatlane CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "chatlane": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "license": "MIT",
14
+ "scripts": {
15
+ "check": "node --check src/index.js && node --check src/cli-error.js && node --check src/config.js && node --check src/constants.js && node --check src/output.js && node --check src/runtime.js && node --check src/help-text.js && node --check src/api.js && node --check src/open-browser.js && node --check src/commands/utils.js && node --check src/commands/help.js && node --check src/commands/login.js && node --check src/commands/logout.js && node --check src/commands/whoami.js && node --check src/commands/ask.js",
16
+ "test": "node --test test/**/*.test.js",
17
+ "prepublishOnly": "npm run check && npm run test"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=18.17.0"
24
+ }
25
+ }
package/src/api.js ADDED
@@ -0,0 +1,155 @@
1
+ export function getErrorDescription(payload) {
2
+ if (!payload || typeof payload !== 'object') return null
3
+ const p = payload
4
+ if (typeof p.error_description === 'string') return p.error_description
5
+ if (typeof p.error === 'string') return p.error
6
+ return null
7
+ }
8
+
9
+ /**
10
+ * @typedef {{
11
+ * deviceCode: string
12
+ * userCode: string
13
+ * verificationUri: string
14
+ * verificationUriComplete: string
15
+ * expiresIn: number
16
+ * interval: number
17
+ * }} DeviceCodePayload
18
+ */
19
+
20
+ /**
21
+ * @typedef {{
22
+ * accessToken: string
23
+ * errorCode: string | null
24
+ * errorDescription: string | null
25
+ * }} DeviceTokenPayload
26
+ */
27
+
28
+ /**
29
+ * @typedef {{
30
+ * userId: string
31
+ * email: string | null
32
+ * organizationId: string
33
+ * organizationName: string | null
34
+ * }} WhoamiPayload
35
+ */
36
+
37
+ export async function requestJson(url, init) {
38
+ const response = await fetch(url, init)
39
+ const payload = await response.json().catch(() => null)
40
+ return { response, payload }
41
+ }
42
+
43
+ export async function requestText(url, init) {
44
+ const response = await fetch(url, init)
45
+ const text = await response.text()
46
+ return { response, text }
47
+ }
48
+
49
+ function withAuthHeaders(init, token) {
50
+ const headers = {
51
+ ...(init?.headers || {}),
52
+ authorization: `Bearer ${token}`,
53
+ }
54
+
55
+ return {
56
+ ...init,
57
+ headers,
58
+ }
59
+ }
60
+
61
+ export function requestAuthedJson(apiBaseUrl, path, token, init) {
62
+ return requestJson(`${apiBaseUrl}${path}`, withAuthHeaders(init, token))
63
+ }
64
+
65
+ export function requestAuthedText(apiBaseUrl, path, token, init) {
66
+ return requestText(`${apiBaseUrl}${path}`, withAuthHeaders(init, token))
67
+ }
68
+
69
+ export function parseDeviceCodePayload(payload) {
70
+ if (!payload || typeof payload !== 'object') return null
71
+ const p = payload
72
+
73
+ const deviceCode =
74
+ typeof p.device_code === 'string' && p.device_code ? p.device_code : null
75
+ const userCode =
76
+ typeof p.user_code === 'string' && p.user_code ? p.user_code : null
77
+ const verificationUriComplete =
78
+ typeof p.verification_uri_complete === 'string' &&
79
+ p.verification_uri_complete
80
+ ? p.verification_uri_complete
81
+ : null
82
+
83
+ if (!deviceCode || !userCode || !verificationUriComplete) {
84
+ return null
85
+ }
86
+
87
+ const verificationUri =
88
+ typeof p.verification_uri === 'string' && p.verification_uri
89
+ ? p.verification_uri
90
+ : verificationUriComplete
91
+ let expiresIn = Number(p.expires_in)
92
+ let interval = Number(p.interval)
93
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) expiresIn = 1800
94
+ if (!Number.isFinite(interval) || interval <= 0) interval = 5
95
+
96
+ /** @type {DeviceCodePayload} */
97
+ const parsed = {
98
+ deviceCode,
99
+ userCode,
100
+ verificationUri,
101
+ verificationUriComplete,
102
+ expiresIn,
103
+ interval,
104
+ }
105
+ return parsed
106
+ }
107
+
108
+ export function parseDeviceTokenPayload(payload) {
109
+ if (!payload || typeof payload !== 'object') {
110
+ return { accessToken: '', errorCode: null, errorDescription: null }
111
+ }
112
+
113
+ const p = payload
114
+ const accessToken =
115
+ typeof p.access_token === 'string' && p.access_token ? p.access_token : ''
116
+ const errorCode = typeof p.error === 'string' ? p.error : null
117
+ const errorDescription = getErrorDescription(p)
118
+
119
+ /** @type {DeviceTokenPayload} */
120
+ const parsed = {
121
+ accessToken,
122
+ errorCode,
123
+ errorDescription,
124
+ }
125
+ return parsed
126
+ }
127
+
128
+ export function parseWhoamiPayload(payload) {
129
+ if (!payload || typeof payload !== 'object') return null
130
+ const p = payload
131
+ if (typeof p.userId !== 'string' || !p.userId) return null
132
+ if (typeof p.organizationId !== 'string' || !p.organizationId) return null
133
+
134
+ /** @type {WhoamiPayload} */
135
+ const parsed = {
136
+ userId: p.userId,
137
+ email: typeof p.email === 'string' && p.email ? p.email : null,
138
+ organizationId: p.organizationId,
139
+ organizationName:
140
+ typeof p.organizationName === 'string' && p.organizationName
141
+ ? p.organizationName
142
+ : null,
143
+ }
144
+ return parsed
145
+ }
146
+
147
+ export function getErrorFromTextResponse(text, fallback = 'Request failed.') {
148
+ try {
149
+ const payload = JSON.parse(text)
150
+ return getErrorDescription(payload) || fallback
151
+ } catch {
152
+ if (text.trim()) return text.trim()
153
+ }
154
+ return fallback
155
+ }
@@ -0,0 +1,14 @@
1
+ export class CliError extends Error {
2
+ constructor(message, options = 1) {
3
+ super(message)
4
+ this.name = 'CliError'
5
+ if (typeof options === 'number') {
6
+ this.exitCode = options
7
+ this.showHelp = false
8
+ return
9
+ }
10
+ this.exitCode =
11
+ typeof options?.exitCode === 'number' ? options.exitCode : 1
12
+ this.showHelp = options?.showHelp === true
13
+ }
14
+ }
@@ -0,0 +1,48 @@
1
+ import { getErrorFromTextResponse, requestAuthedText } from '../api.js'
2
+ import { CliError } from '../cli-error.js'
3
+ import { MESSAGES } from '../constants.js'
4
+ import { askHelp } from '../help-text.js'
5
+ import { isHelpFlag } from '../output.js'
6
+ import { getApiBaseUrl, requireToken } from '../runtime.js'
7
+
8
+ export async function runAsk(args, ctx) {
9
+ if (args.length === 1 && isHelpFlag(args[0])) {
10
+ ctx.print(askHelp())
11
+ return
12
+ }
13
+
14
+ if (args.length === 0) {
15
+ throw new CliError(MESSAGES.missingQuestion)
16
+ }
17
+
18
+ const question = args.join(' ').trim()
19
+ if (!question) {
20
+ throw new CliError(MESSAGES.missingQuestion)
21
+ }
22
+
23
+ const token = requireToken()
24
+ const apiBaseUrl = getApiBaseUrl()
25
+
26
+ const { response, text } = await requestAuthedText(
27
+ apiBaseUrl,
28
+ '/cli/ask',
29
+ token,
30
+ {
31
+ method: 'POST',
32
+ headers: {
33
+ 'content-type': 'application/json',
34
+ },
35
+ body: JSON.stringify({ question }),
36
+ },
37
+ )
38
+
39
+ if (!response.ok) {
40
+ if (response.status === 401) {
41
+ throw new CliError(MESSAGES.unauthorized)
42
+ }
43
+
44
+ throw new CliError(getErrorFromTextResponse(text))
45
+ }
46
+
47
+ ctx.print(text.trim())
48
+ }
@@ -0,0 +1,8 @@
1
+ import { rootHelp } from '../help-text.js'
2
+ import { ensureNoExtraArgs } from './utils.js'
3
+
4
+ export async function runHelp(args, ctx) {
5
+ const handled = ensureNoExtraArgs(args, rootHelp(), ctx.print)
6
+ if (handled) return
7
+ ctx.print(rootHelp())
8
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ getErrorDescription,
3
+ parseDeviceCodePayload,
4
+ parseDeviceTokenPayload,
5
+ requestJson,
6
+ } from '../api.js'
7
+ import { CliError } from '../cli-error.js'
8
+ import { DEVICE_GRANT_TYPE } from '../constants.js'
9
+ import { getConfigPath, updateConfig } from '../config.js'
10
+ import { loginHelp } from '../help-text.js'
11
+ import { openBrowser } from '../open-browser.js'
12
+ import { getApiBaseUrl, getClientId } from '../runtime.js'
13
+ import { ensureNoExtraArgs } from './utils.js'
14
+
15
+ export async function runLogin(args, ctx) {
16
+ const handled = ensureNoExtraArgs(args, loginHelp(getConfigPath()), ctx.print)
17
+ if (handled) return
18
+
19
+ const apiBaseUrl = getApiBaseUrl()
20
+ const clientId = getClientId()
21
+
22
+ const { response, payload } = await requestJson(
23
+ `${apiBaseUrl}/auth/device/code`,
24
+ {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: JSON.stringify({ client_id: clientId }),
28
+ },
29
+ )
30
+
31
+ if (!response.ok) {
32
+ const description = getErrorDescription(payload) || 'Failed to start login.'
33
+ throw new CliError(description)
34
+ }
35
+
36
+ const deviceCodePayload = parseDeviceCodePayload(payload)
37
+ if (!deviceCodePayload) {
38
+ throw new CliError('Invalid device authorization response.')
39
+ }
40
+
41
+ ctx.print(`User code: ${deviceCodePayload.userCode}`)
42
+ ctx.print(`Verification URL: ${deviceCodePayload.verificationUri}`)
43
+ const opened = openBrowser(deviceCodePayload.verificationUriComplete)
44
+ if (opened) {
45
+ ctx.print('Opened your browser for approval.')
46
+ } else {
47
+ ctx.print('Could not open browser automatically. Open the URL above manually.')
48
+ }
49
+
50
+ const deadline = Date.now() + deviceCodePayload.expiresIn * 1000
51
+ let interval = deviceCodePayload.interval
52
+
53
+ while (Date.now() < deadline) {
54
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000))
55
+
56
+ const tokenResponse = await requestJson(`${apiBaseUrl}/auth/device/token`, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({
60
+ grant_type: DEVICE_GRANT_TYPE,
61
+ device_code: deviceCodePayload.deviceCode,
62
+ client_id: clientId,
63
+ }),
64
+ })
65
+
66
+ const tokenPayload = parseDeviceTokenPayload(tokenResponse.payload)
67
+ if (tokenResponse.response.ok) {
68
+ if (!tokenPayload.accessToken) {
69
+ throw new CliError('Login succeeded but access token was missing.')
70
+ }
71
+
72
+ updateConfig({
73
+ apiBaseUrl,
74
+ token: tokenPayload.accessToken,
75
+ })
76
+ ctx.print('Login successful.')
77
+ return
78
+ }
79
+
80
+ const errorCode = tokenPayload.errorCode
81
+ if (errorCode === 'authorization_pending') continue
82
+ if (errorCode === 'slow_down') {
83
+ interval += 5
84
+ continue
85
+ }
86
+ if (errorCode === 'access_denied') {
87
+ throw new CliError('Login was denied in browser.')
88
+ }
89
+ if (errorCode === 'expired_token') {
90
+ throw new CliError('Login request expired. Run `chatlane login` again.')
91
+ }
92
+
93
+ const description =
94
+ tokenPayload.errorDescription || 'Device login failed.'
95
+ throw new CliError(description)
96
+ }
97
+
98
+ throw new CliError('Login timed out. Run `chatlane login` again.')
99
+ }
@@ -0,0 +1,11 @@
1
+ import { updateConfig } from '../config.js'
2
+ import { logoutHelp } from '../help-text.js'
3
+ import { ensureNoExtraArgs } from './utils.js'
4
+
5
+ export async function runLogout(args, ctx) {
6
+ const handled = ensureNoExtraArgs(args, logoutHelp(), ctx.print)
7
+ if (handled) return
8
+
9
+ updateConfig({ token: null })
10
+ ctx.print('Logged out.')
11
+ }
@@ -0,0 +1,13 @@
1
+ import { CliError } from '../cli-error.js'
2
+ import { isHelpFlag } from '../output.js'
3
+
4
+ export function ensureNoExtraArgs(args, helpText, print) {
5
+ if (args.length === 0) return false
6
+ if (args.length === 1 && isHelpFlag(args[0])) {
7
+ print(helpText)
8
+ return true
9
+ }
10
+ throw new CliError(`Unexpected arguments: ${args.join(' ')}`, {
11
+ showHelp: true,
12
+ })
13
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ getErrorDescription,
3
+ parseWhoamiPayload,
4
+ requestAuthedJson,
5
+ } from '../api.js'
6
+ import { CliError } from '../cli-error.js'
7
+ import { MESSAGES } from '../constants.js'
8
+ import { requireToken, getApiBaseUrl } from '../runtime.js'
9
+ import { whoamiHelp } from '../help-text.js'
10
+ import { ensureNoExtraArgs } from './utils.js'
11
+
12
+ export async function runWhoami(args, ctx) {
13
+ const handled = ensureNoExtraArgs(args, whoamiHelp(), ctx.print)
14
+ if (handled) return
15
+
16
+ const token = requireToken()
17
+ const apiBaseUrl = getApiBaseUrl()
18
+
19
+ const { response, payload } = await requestAuthedJson(
20
+ apiBaseUrl,
21
+ '/cli/whoami',
22
+ token,
23
+ { method: 'GET' },
24
+ )
25
+
26
+ if (!response.ok) {
27
+ if (response.status === 401) {
28
+ throw new CliError(MESSAGES.unauthorized)
29
+ }
30
+ throw new CliError(
31
+ getErrorDescription(payload) || 'Failed to fetch identity.',
32
+ )
33
+ }
34
+
35
+ const whoami = parseWhoamiPayload(payload)
36
+ if (!whoami) {
37
+ throw new CliError('Invalid whoami response payload.')
38
+ }
39
+
40
+ ctx.print(`User: ${whoami.email || 'unknown'} (${whoami.userId})`)
41
+ ctx.print(
42
+ `Workspace: ${whoami.organizationName || 'unknown'} (${whoami.organizationId})`,
43
+ )
44
+ }
package/src/config.js ADDED
@@ -0,0 +1,38 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const CONFIG_DIR = path.join(homedir(), '.config', 'chatlane')
6
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
7
+
8
+ export function getConfigPath() {
9
+ return CONFIG_PATH
10
+ }
11
+
12
+ export function readConfig() {
13
+ if (!existsSync(CONFIG_PATH)) return {}
14
+
15
+ try {
16
+ const raw = readFileSync(CONFIG_PATH, 'utf8')
17
+ const parsed = JSON.parse(raw)
18
+ if (!parsed || typeof parsed !== 'object') return {}
19
+ return parsed
20
+ } catch {
21
+ return {}
22
+ }
23
+ }
24
+
25
+ export function writeConfig(nextConfig) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true })
27
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8')
28
+ try {
29
+ chmodSync(CONFIG_PATH, 0o600)
30
+ } catch {
31
+ // Ignore permission change failures on unsupported platforms.
32
+ }
33
+ }
34
+
35
+ export function updateConfig(patch) {
36
+ const current = readConfig()
37
+ writeConfig({ ...current, ...patch })
38
+ }
@@ -0,0 +1,9 @@
1
+ export const DEFAULT_API_BASE_URL = 'https://api.chatlane.co'
2
+ export const DEFAULT_CLIENT_ID = 'chatlane-cli'
3
+ export const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
4
+
5
+ export const MESSAGES = {
6
+ missingQuestion: 'Missing question. Usage: chatlane ask "<question>"',
7
+ notLoggedIn: 'Not logged in. Run `chatlane login` first.',
8
+ unauthorized: 'Unauthorized. Run `chatlane login` again.',
9
+ }
@@ -0,0 +1,62 @@
1
+ export function rootHelp() {
2
+ return `Chatlane CLI
3
+
4
+ Usage:
5
+ chatlane <command> [options]
6
+ chatlane --help
7
+
8
+ Commands:
9
+ help Show this help output
10
+ login Sign in via browser device flow
11
+ logout Clear local credentials
12
+ whoami Show authenticated user and workspace
13
+ ask "<question>" Ask a question using workspace memory
14
+
15
+ Examples:
16
+ chatlane login
17
+ chatlane whoami
18
+ chatlane ask "How does onboarding work?"
19
+ chatlane logout
20
+ `
21
+ }
22
+
23
+ export function loginHelp(configPath) {
24
+ return `Usage:
25
+ chatlane login
26
+ chatlane login --help
27
+
28
+ Description:
29
+ Starts browser-based device login and stores token in:
30
+ ${configPath}
31
+ `
32
+ }
33
+
34
+ export function logoutHelp() {
35
+ return `Usage:
36
+ chatlane logout
37
+ chatlane logout --help
38
+
39
+ Description:
40
+ Clears locally stored credentials.
41
+ `
42
+ }
43
+
44
+ export function whoamiHelp() {
45
+ return `Usage:
46
+ chatlane whoami
47
+ chatlane whoami --help
48
+
49
+ Description:
50
+ Shows authenticated user and active workspace context.
51
+ `
52
+ }
53
+
54
+ export function askHelp() {
55
+ return `Usage:
56
+ chatlane ask "<question>"
57
+ chatlane ask --help
58
+
59
+ Description:
60
+ Returns a single plain-text answer grounded in workspace memory.
61
+ `
62
+ }
package/src/index.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { CliError } from './cli-error.js'
4
+ import { rootHelp } from './help-text.js'
5
+ import { isHelpFlag, print, printErr } from './output.js'
6
+ import { runAsk } from './commands/ask.js'
7
+ import { runHelp } from './commands/help.js'
8
+ import { runLogin } from './commands/login.js'
9
+ import { runLogout } from './commands/logout.js'
10
+ import { runWhoami } from './commands/whoami.js'
11
+
12
+ const commandMap = {
13
+ ask: runAsk,
14
+ help: runHelp,
15
+ login: runLogin,
16
+ logout: runLogout,
17
+ whoami: runWhoami,
18
+ }
19
+
20
+ async function main() {
21
+ const args = process.argv.slice(2)
22
+ const [command, ...rest] = args
23
+
24
+ if (!command || isHelpFlag(command)) {
25
+ print(rootHelp())
26
+ return
27
+ }
28
+
29
+ const commandHandler = commandMap[command]
30
+ if (!commandHandler) {
31
+ throw new CliError(`Unknown command: ${command}`, { showHelp: true })
32
+ }
33
+
34
+ await commandHandler(rest, { print })
35
+ }
36
+
37
+ main().catch((error) => {
38
+ const exitCode =
39
+ error instanceof CliError && typeof error.exitCode === 'number'
40
+ ? error.exitCode
41
+ : 1
42
+ const message =
43
+ error instanceof Error && error.message ? error.message : 'Unknown error'
44
+ printErr(message)
45
+ if (!(error instanceof CliError && error.showHelp)) {
46
+ process.exit(exitCode)
47
+ return
48
+ }
49
+ print(rootHelp())
50
+ process.exit(exitCode)
51
+ })
@@ -0,0 +1,28 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ function spawnDetached(command, args) {
4
+ const child = spawn(command, args, {
5
+ detached: true,
6
+ stdio: 'ignore',
7
+ })
8
+ child.unref()
9
+ }
10
+
11
+ export function openBrowser(url) {
12
+ try {
13
+ if (process.platform === 'darwin') {
14
+ spawnDetached('open', [url])
15
+ return true
16
+ }
17
+
18
+ if (process.platform === 'win32') {
19
+ spawnDetached('cmd', ['/c', 'start', '', url])
20
+ return true
21
+ }
22
+
23
+ spawnDetached('xdg-open', [url])
24
+ return true
25
+ } catch {
26
+ return false
27
+ }
28
+ }
package/src/output.js ADDED
@@ -0,0 +1,11 @@
1
+ export function print(text) {
2
+ process.stdout.write(`${text}\n`)
3
+ }
4
+
5
+ export function printErr(text) {
6
+ process.stderr.write(`${text}\n`)
7
+ }
8
+
9
+ export function isHelpFlag(arg) {
10
+ return arg === '--help' || arg === '-h'
11
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readConfig } from './config.js'
2
+ import { CliError } from './cli-error.js'
3
+ import {
4
+ DEFAULT_API_BASE_URL,
5
+ DEFAULT_CLIENT_ID,
6
+ MESSAGES,
7
+ } from './constants.js'
8
+
9
+ export function getApiBaseUrl() {
10
+ return (
11
+ process.env.CHATLANE_API_URL ||
12
+ readConfig().apiBaseUrl ||
13
+ DEFAULT_API_BASE_URL
14
+ ).replace(/\/+$/, '')
15
+ }
16
+
17
+ export function getClientId() {
18
+ return process.env.CHATLANE_CLIENT_ID || DEFAULT_CLIENT_ID
19
+ }
20
+
21
+ export function getToken() {
22
+ const envToken = process.env.CHATLANE_TOKEN
23
+ if (typeof envToken === 'string' && envToken.trim()) return envToken.trim()
24
+ const fileToken = readConfig().token
25
+ return typeof fileToken === 'string' && fileToken.trim()
26
+ ? fileToken.trim()
27
+ : null
28
+ }
29
+
30
+ export function requireToken() {
31
+ const token = getToken()
32
+ if (!token) {
33
+ throw new CliError(MESSAGES.notLoggedIn)
34
+ }
35
+ return token
36
+ }