@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 +15 -0
- package/package.json +25 -0
- package/src/api.js +155 -0
- package/src/cli-error.js +14 -0
- package/src/commands/ask.js +48 -0
- package/src/commands/help.js +8 -0
- package/src/commands/login.js +99 -0
- package/src/commands/logout.js +11 -0
- package/src/commands/utils.js +13 -0
- package/src/commands/whoami.js +44 -0
- package/src/config.js +38 -0
- package/src/constants.js +9 -0
- package/src/help-text.js +62 -0
- package/src/index.js +51 -0
- package/src/open-browser.js +28 -0
- package/src/output.js +11 -0
- package/src/runtime.js +36 -0
package/README.md
ADDED
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
|
+
}
|
package/src/cli-error.js
ADDED
|
@@ -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,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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|
package/src/help-text.js
ADDED
|
@@ -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
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
|
+
}
|