mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
chore: remove @memohai/cli
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -1,2 +0,0 @@
|
||||
# @memohai/cli
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "@memohai/cli",
|
||||
"version": "0.5.0",
|
||||
"description": "Command line interface and core API for Memoh",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./core": "./src/core/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./utils": "./src/utils/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"bin": {
|
||||
"memoh": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/cli/index.ts",
|
||||
"dev": "bun run --watch src/cli/index.ts",
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@memohai/sdk": "workspace:*",
|
||||
"commander": "^12.1.0",
|
||||
"chalk": "^5.4.1",
|
||||
"ora": "^8.1.1",
|
||||
"inquirer": "^12.3.0",
|
||||
"table": "^6.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"bun-types": "latest",
|
||||
"@types/bun": "latest",
|
||||
"tsup": "^8.4.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
import readline from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
|
||||
import {
|
||||
getBots,
|
||||
postBots,
|
||||
putBotsById,
|
||||
deleteBotsById,
|
||||
getModels,
|
||||
type BotsBot,
|
||||
type BotsCreateBotRequest,
|
||||
type BotsUpdateBotRequest,
|
||||
type ModelsGetResponse,
|
||||
} from '@memohai/sdk'
|
||||
import { client } from '@memohai/sdk/client'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId } from './shared'
|
||||
import { streamChat } from './stream'
|
||||
|
||||
const getModelId = (item: ModelsGetResponse) => item.model_id ?? ''
|
||||
const getModelType = (item: ModelsGetResponse) => item.type ?? 'chat'
|
||||
|
||||
const ensureModelsReady = async () => {
|
||||
ensureAuth()
|
||||
const [chatResult, embeddingResult] = await Promise.all([
|
||||
getModels({ query: { type: 'chat' }, throwOnError: true }),
|
||||
getModels({ query: { type: 'embedding' }, throwOnError: true }),
|
||||
])
|
||||
const chatModels = chatResult.data ?? []
|
||||
const embeddingModels = embeddingResult.data ?? []
|
||||
if (!Array.isArray(chatModels) || chatModels.length === 0 || !Array.isArray(embeddingModels) || embeddingModels.length === 0) {
|
||||
console.log(chalk.red('Model configuration incomplete.'))
|
||||
console.log(chalk.yellow('At least one chat model and one embedding model are required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const renderBotsTable = (items: BotsBot[]) => {
|
||||
const rows: string[][] = [['ID', 'Name', 'Type', 'Active', 'Owner']]
|
||||
for (const bot of items) {
|
||||
rows.push([
|
||||
bot.id ?? '',
|
||||
bot.display_name || bot.id || '',
|
||||
bot.type ?? '',
|
||||
bot.is_active ? 'yes' : 'no',
|
||||
bot.owner_user_id ?? '',
|
||||
])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
export const registerBotCommands = (program: Command) => {
|
||||
const bot = program.command('bot').description('Bot management')
|
||||
|
||||
bot
|
||||
.command('list')
|
||||
.description('List bots')
|
||||
.option('--owner <user_id>', 'Filter by owner user ID (admin only)')
|
||||
.action(async (opts) => {
|
||||
ensureAuth()
|
||||
const { data } = await getBots({
|
||||
query: opts.owner ? { owner_id: opts.owner } : undefined,
|
||||
throwOnError: true,
|
||||
})
|
||||
const items = data.items ?? []
|
||||
if (!items.length) {
|
||||
console.log(chalk.yellow('No bots found.'))
|
||||
return
|
||||
}
|
||||
console.log(renderBotsTable(items))
|
||||
})
|
||||
|
||||
bot
|
||||
.command('create')
|
||||
.description('Create a bot')
|
||||
.option('--type <type>', 'Bot type (personal, public)')
|
||||
.option('--name <name>', 'Bot display name')
|
||||
.option('--avatar <url>', 'Bot avatar URL')
|
||||
.option('--active', 'Set bot active')
|
||||
.option('--inactive', 'Set bot inactive')
|
||||
.action(async (opts) => {
|
||||
if (opts.active && opts.inactive) {
|
||||
console.log(chalk.red('Use only one of --active or --inactive.'))
|
||||
process.exit(1)
|
||||
}
|
||||
ensureAuth()
|
||||
let type = opts.type
|
||||
if (!type) {
|
||||
const answer = await inquirer.prompt<{ type: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'type',
|
||||
message: 'Bot type:',
|
||||
choices: ['personal', 'public'],
|
||||
},
|
||||
])
|
||||
type = answer.type
|
||||
}
|
||||
if (!['personal', 'public'].includes(String(type))) {
|
||||
console.log(chalk.red('Bot type must be personal or public.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const name = opts.name ?? (await inquirer.prompt<{ name: string }>([
|
||||
{ type: 'input', name: 'name', message: 'Bot name (optional):', default: '' },
|
||||
])).name
|
||||
const body: Record<string, unknown> = {
|
||||
type: String(type),
|
||||
}
|
||||
if (String(name).trim()) body.display_name = String(name).trim()
|
||||
if (opts.avatar) body.avatar_url = String(opts.avatar).trim()
|
||||
if (opts.active) body.is_active = true
|
||||
if (opts.inactive) body.is_active = false
|
||||
const spinner = ora('Creating bot...').start()
|
||||
try {
|
||||
const { data } = await postBots({
|
||||
body: body as BotsCreateBotRequest,
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed(`Bot created: ${data.display_name || data.id}`)
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to create bot')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
bot
|
||||
.command('update')
|
||||
.description('Update bot info')
|
||||
.argument('[id]')
|
||||
.option('--name <name>', 'Bot display name')
|
||||
.option('--avatar <url>', 'Bot avatar URL')
|
||||
.option('--active', 'Set bot active')
|
||||
.option('--inactive', 'Set bot inactive')
|
||||
.action(async (id, opts) => {
|
||||
if (opts.active && opts.inactive) {
|
||||
console.log(chalk.red('Use only one of --active or --inactive.'))
|
||||
process.exit(1)
|
||||
}
|
||||
ensureAuth()
|
||||
const botId = await resolveBotId(id)
|
||||
const body: Record<string, unknown> = {}
|
||||
if (opts.name) body.display_name = String(opts.name).trim()
|
||||
if (opts.avatar) body.avatar_url = String(opts.avatar).trim()
|
||||
if (opts.active) body.is_active = true
|
||||
if (opts.inactive) body.is_active = false
|
||||
if (Object.keys(body).length === 0) {
|
||||
const answers = await inquirer.prompt<{ name: string; avatar: string; status: string }>([
|
||||
{ type: 'input', name: 'name', message: 'Bot name (leave empty to skip):', default: '' },
|
||||
{ type: 'input', name: 'avatar', message: 'Bot avatar URL (leave empty to skip):', default: '' },
|
||||
{
|
||||
type: 'list',
|
||||
name: 'status',
|
||||
message: 'Bot status:',
|
||||
choices: [
|
||||
{ name: 'keep', value: 'keep' },
|
||||
{ name: 'active', value: 'active' },
|
||||
{ name: 'inactive', value: 'inactive' },
|
||||
],
|
||||
},
|
||||
])
|
||||
if (answers.name.trim()) body.display_name = answers.name.trim()
|
||||
if (answers.avatar.trim()) body.avatar_url = answers.avatar.trim()
|
||||
if (answers.status === 'active') body.is_active = true
|
||||
if (answers.status === 'inactive') body.is_active = false
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
console.log(chalk.red('No updates provided.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const spinner = ora('Updating bot...').start()
|
||||
try {
|
||||
await putBotsById({
|
||||
path: { id: botId },
|
||||
body: body as BotsUpdateBotRequest,
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Bot updated')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update bot')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
bot
|
||||
.command('delete')
|
||||
.description('Delete a bot')
|
||||
.argument('[id]')
|
||||
.action(async (id) => {
|
||||
ensureAuth()
|
||||
const botId = await resolveBotId(id)
|
||||
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
|
||||
{ type: 'confirm', name: 'confirmed', message: `Delete bot ${botId}?`, default: false },
|
||||
])
|
||||
if (!confirmed) return
|
||||
const spinner = ora('Deleting bot...').start()
|
||||
try {
|
||||
await deleteBotsById({
|
||||
path: { id: botId },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Bot deleted')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to delete bot')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
bot
|
||||
.command('chat')
|
||||
.description('Chat with a bot (stream)')
|
||||
.argument('[id]')
|
||||
.action(async (id) => {
|
||||
await ensureModelsReady()
|
||||
ensureAuth()
|
||||
const botId = await resolveBotId(id)
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
|
||||
while (true) {
|
||||
const line = (await rl.question(chalk.cyan('> '))).trim()
|
||||
if (!line) {
|
||||
if (!input.isTTY && input.readableEnded) break
|
||||
continue
|
||||
}
|
||||
if (line.toLowerCase() === 'exit') {
|
||||
break
|
||||
}
|
||||
await streamChat(line, botId)
|
||||
}
|
||||
rl.close()
|
||||
})
|
||||
|
||||
bot
|
||||
.command('set-model')
|
||||
.description('Enable model for a bot')
|
||||
.argument('[id]')
|
||||
.option('--as <usage>', 'chat | memory | embedding')
|
||||
.option('--model <model_id>', 'Model ID')
|
||||
.action(async (id, opts) => {
|
||||
ensureAuth()
|
||||
const botId = await resolveBotId(id)
|
||||
let enableAs = opts.as
|
||||
if (!enableAs) {
|
||||
const answer = await inquirer.prompt<{ usage: string }>([{
|
||||
type: 'list',
|
||||
name: 'usage',
|
||||
message: 'Enable as:',
|
||||
choices: ['chat', 'memory', 'embedding'],
|
||||
}])
|
||||
enableAs = answer.usage
|
||||
}
|
||||
enableAs = String(enableAs).trim()
|
||||
if (!['chat', 'memory', 'embedding'].includes(enableAs)) {
|
||||
console.log(chalk.red('Enable as must be one of chat, memory, embedding.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const { data: models } = await getModels({ throwOnError: true })
|
||||
const modelList = Array.isArray(models) ? models as ModelsGetResponse[] : []
|
||||
const requiredType = enableAs === 'embedding' ? 'embedding' : 'chat'
|
||||
const candidates = modelList.filter(m => getModelType(m) === requiredType)
|
||||
if (candidates.length === 0) {
|
||||
console.log(chalk.red(`No ${requiredType} models available.`))
|
||||
process.exit(1)
|
||||
}
|
||||
let modelId = opts.model
|
||||
if (!modelId) {
|
||||
const answer = await inquirer.prompt<{ model: string }>([{
|
||||
type: 'list',
|
||||
name: 'model',
|
||||
message: 'Select model:',
|
||||
choices: candidates.map(m => getModelId(m)),
|
||||
}])
|
||||
modelId = answer.model
|
||||
}
|
||||
const selected = candidates.find(m => getModelId(m) === modelId)
|
||||
if (!selected) {
|
||||
console.log(chalk.red('Selected model not found.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const body: Record<string, unknown> = {}
|
||||
if (enableAs === 'chat') body.chat_model_id = getModelId(selected)
|
||||
if (enableAs === 'memory') body.memory_model_id = getModelId(selected)
|
||||
if (enableAs === 'embedding') body.embedding_model_id = getModelId(selected)
|
||||
const spinner = ora('Updating bot settings...').start()
|
||||
try {
|
||||
// Use raw client because bot_id path parameter is not typed in SDK
|
||||
await client.put({
|
||||
url: `/bots/${encodeURIComponent(botId)}/settings`,
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Model enabled')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to enable model')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
|
||||
import { apiRequest } from '../core/api'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId } from './shared'
|
||||
import { getBaseURL, readConfig } from '../utils/store'
|
||||
|
||||
type ChannelFieldSchema = {
|
||||
type: 'string' | 'secret' | 'bool' | 'number' | 'enum'
|
||||
required: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
enum?: string[]
|
||||
example?: unknown
|
||||
}
|
||||
|
||||
type ChannelConfigSchema = {
|
||||
version: number
|
||||
fields: Record<string, ChannelFieldSchema>
|
||||
}
|
||||
|
||||
type ChannelMeta = {
|
||||
type: string
|
||||
display_name: string
|
||||
configless: boolean
|
||||
capabilities: Record<string, boolean>
|
||||
config_schema: ChannelConfigSchema
|
||||
user_config_schema: ChannelConfigSchema
|
||||
}
|
||||
|
||||
type ChannelUserBinding = {
|
||||
id: string
|
||||
channel_type: string
|
||||
user_id: string
|
||||
config: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type ChannelConfig = {
|
||||
id: string
|
||||
bot_id: string
|
||||
channel_type: string
|
||||
credentials: Record<string, unknown>
|
||||
external_identity: string
|
||||
self_identity: Record<string, unknown>
|
||||
routing: Record<string, unknown>
|
||||
capabilities: Record<string, unknown>
|
||||
disabled: boolean
|
||||
verified_at: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const readInboundMode = (credentials: Record<string, unknown>) => {
|
||||
const raw = credentials.inboundMode ?? credentials.inbound_mode
|
||||
if (typeof raw !== 'string') return ''
|
||||
return raw.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const buildWebhookCallbackUrl = (configId: string) => {
|
||||
const baseUrl = getBaseURL(readConfig()).replace(/\/+$/, '')
|
||||
return `${baseUrl}/channels/feishu/webhook/${encodeURIComponent(configId)}`
|
||||
}
|
||||
|
||||
const printWebhookCallbackIfEnabled = (channelType: string, config: ChannelConfig) => {
|
||||
if (channelType !== 'feishu') return
|
||||
if (readInboundMode(config.credentials || {}) !== 'webhook') return
|
||||
const configId = String(config.id || '').trim()
|
||||
if (!configId) {
|
||||
console.log(chalk.yellow('Webhook is enabled, but config id is missing so callback URL cannot be generated yet.'))
|
||||
return
|
||||
}
|
||||
console.log(chalk.cyan(`Webhook callback URL: ${buildWebhookCallbackUrl(configId)}`))
|
||||
}
|
||||
|
||||
const renderChannelsTable = (items: ChannelMeta[]) => {
|
||||
const rows: string[][] = [['Type', 'Name', 'Configless']]
|
||||
for (const item of items) {
|
||||
rows.push([item.type, item.display_name, item.configless ? 'yes' : 'no'])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
const fetchChannels = async (token: ReturnType<typeof ensureAuth>) => {
|
||||
return apiRequest<ChannelMeta[]>('/channels', {}, token)
|
||||
}
|
||||
|
||||
const resolveChannelType = async (
|
||||
token: ReturnType<typeof ensureAuth>,
|
||||
preset?: string,
|
||||
options?: { allowConfigless?: boolean }
|
||||
) => {
|
||||
if (preset && preset.trim()) {
|
||||
return preset.trim()
|
||||
}
|
||||
const channels = await fetchChannels(token)
|
||||
const allowConfigless = options?.allowConfigless ?? false
|
||||
const candidates = channels.filter(item => allowConfigless || !item.configless)
|
||||
if (candidates.length === 0) {
|
||||
console.log(chalk.yellow('No configurable channels available.'))
|
||||
process.exit(0)
|
||||
}
|
||||
const { channelType } = await inquirer.prompt<{ channelType: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'channelType',
|
||||
message: 'Select channel type:',
|
||||
choices: candidates.map(item => ({
|
||||
name: `${item.display_name} (${item.type})`,
|
||||
value: item.type,
|
||||
})),
|
||||
},
|
||||
])
|
||||
return channelType
|
||||
}
|
||||
|
||||
const collectFeishuCredentials = async (opts: Record<string, unknown>) => {
|
||||
let appId = typeof opts.app_id === 'string' ? opts.app_id : undefined
|
||||
let appSecret = typeof opts.app_secret === 'string' ? opts.app_secret : undefined
|
||||
let encryptKey = typeof opts.encrypt_key === 'string' ? opts.encrypt_key : undefined
|
||||
let verificationToken = typeof opts.verification_token === 'string' ? opts.verification_token : undefined
|
||||
let region = typeof opts.region === 'string' ? opts.region : undefined
|
||||
let inboundMode = typeof opts.inbound_mode === 'string' ? opts.inbound_mode : undefined
|
||||
|
||||
const questions = []
|
||||
if (!appId) questions.push({ type: 'input', name: 'appId', message: 'Feishu App ID:' })
|
||||
if (!appSecret) questions.push({ type: 'password', name: 'appSecret', message: 'Feishu App Secret:' })
|
||||
if (!encryptKey) {
|
||||
questions.push({ type: 'input', name: 'encryptKey', message: 'Encrypt Key (optional):', default: '' })
|
||||
}
|
||||
if (!verificationToken) {
|
||||
questions.push({ type: 'input', name: 'verificationToken', message: 'Verification Token (optional):', default: '' })
|
||||
}
|
||||
if (!region) {
|
||||
questions.push({
|
||||
type: 'list',
|
||||
name: 'region',
|
||||
message: 'Region:',
|
||||
choices: [
|
||||
{ name: 'Feishu (open.feishu.cn)', value: 'feishu' },
|
||||
{ name: 'Lark (open.larksuite.com)', value: 'lark' },
|
||||
],
|
||||
default: 'feishu',
|
||||
})
|
||||
}
|
||||
if (!inboundMode) {
|
||||
questions.push({
|
||||
type: 'list',
|
||||
name: 'inboundMode',
|
||||
message: 'Inbound mode:',
|
||||
choices: [
|
||||
{ name: 'WebSocket', value: 'websocket' },
|
||||
{ name: 'Webhook', value: 'webhook' },
|
||||
],
|
||||
default: 'websocket',
|
||||
})
|
||||
}
|
||||
const answers = questions.length ? await inquirer.prompt<Record<string, string>>(questions) : {}
|
||||
|
||||
appId = appId ?? answers.appId
|
||||
appSecret = appSecret ?? answers.appSecret
|
||||
encryptKey = encryptKey ?? answers.encryptKey
|
||||
verificationToken = verificationToken ?? answers.verificationToken
|
||||
region = region ?? answers.region
|
||||
inboundMode = inboundMode ?? answers.inboundMode
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
appId: String(appId).trim(),
|
||||
appSecret: String(appSecret).trim(),
|
||||
region: String(region || 'feishu').trim(),
|
||||
inboundMode: String(inboundMode || 'websocket').trim(),
|
||||
}
|
||||
if (String(encryptKey || '').trim()) payload.encryptKey = String(encryptKey).trim()
|
||||
if (String(verificationToken || '').trim()) payload.verificationToken = String(verificationToken).trim()
|
||||
return payload
|
||||
}
|
||||
|
||||
const collectFeishuUserConfig = async (opts: Record<string, unknown>) => {
|
||||
let openId = typeof opts.open_id === 'string' ? opts.open_id : undefined
|
||||
let userId = typeof opts.user_id === 'string' ? opts.user_id : undefined
|
||||
|
||||
if (!openId && !userId) {
|
||||
const answers = await inquirer.prompt<{ kind: 'open_id' | 'user_id'; value: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'kind',
|
||||
message: 'Bind using:',
|
||||
choices: [
|
||||
{ name: 'Open ID', value: 'open_id' },
|
||||
{ name: 'User ID', value: 'user_id' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'value',
|
||||
message: 'Value:',
|
||||
},
|
||||
])
|
||||
if (answers.kind === 'open_id') openId = answers.value
|
||||
if (answers.kind === 'user_id') userId = answers.value
|
||||
}
|
||||
if (!openId && !userId) {
|
||||
console.log(chalk.red('open_id or user_id is required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const config: Record<string, unknown> = {}
|
||||
if (openId) config.open_id = String(openId).trim()
|
||||
if (userId) config.user_id = String(userId).trim()
|
||||
return config
|
||||
}
|
||||
|
||||
export const registerChannelCommands = (program: Command) => {
|
||||
const channel = program.command('channel').description('Channel management')
|
||||
|
||||
channel
|
||||
.command('list')
|
||||
.description('List available channels')
|
||||
.action(async () => {
|
||||
const token = ensureAuth()
|
||||
const channels = await fetchChannels(token)
|
||||
if (!channels.length) {
|
||||
console.log(chalk.yellow('No channels available.'))
|
||||
return
|
||||
}
|
||||
console.log(renderChannelsTable(channels))
|
||||
})
|
||||
|
||||
channel
|
||||
.command('info')
|
||||
.description('Show channel meta and schema')
|
||||
.argument('[type]')
|
||||
.action(async (type) => {
|
||||
const token = ensureAuth()
|
||||
const channelType = await resolveChannelType(token, type, { allowConfigless: true })
|
||||
const meta = await apiRequest<ChannelMeta>(`/channels/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(meta, null, 2))
|
||||
})
|
||||
|
||||
const config = channel.command('config').description('Bot channel configuration')
|
||||
|
||||
config
|
||||
.command('get')
|
||||
.description('Get bot channel config')
|
||||
.argument('[bot_id]')
|
||||
.option('--type <type>', 'Channel type')
|
||||
.action(async (botId, opts) => {
|
||||
const token = ensureAuth()
|
||||
const resolvedBotId = await resolveBotId(token, botId)
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
const resp = await apiRequest<ChannelConfig>(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(resp, null, 2))
|
||||
printWebhookCallbackIfEnabled(channelType, resp)
|
||||
})
|
||||
|
||||
config
|
||||
.command('set')
|
||||
.description('Set bot channel config')
|
||||
.argument('[bot_id]')
|
||||
.option('--type <type>', 'Channel type (feishu)')
|
||||
.option('--app_id <app_id>')
|
||||
.option('--app_secret <app_secret>')
|
||||
.option('--encrypt_key <encrypt_key>')
|
||||
.option('--verification_token <verification_token>')
|
||||
.option('--region <region>', 'feishu|lark')
|
||||
.option('--inbound_mode <inbound_mode>', 'websocket|webhook')
|
||||
.action(async (botId, opts) => {
|
||||
const token = ensureAuth()
|
||||
const resolvedBotId = await resolveBotId(token, botId)
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
if (channelType !== 'feishu') {
|
||||
console.log(chalk.red(`Channel type ${channelType} is not supported by this command.`))
|
||||
process.exit(1)
|
||||
}
|
||||
const credentials = await collectFeishuCredentials(opts)
|
||||
const spinner = ora('Updating channel config...').start()
|
||||
try {
|
||||
const resp = await apiRequest<ChannelConfig>(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ credentials }),
|
||||
}, token)
|
||||
spinner.succeed('Channel config updated')
|
||||
printWebhookCallbackIfEnabled(channelType, resp)
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update channel config')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
const binding = channel.command('bind').description('User channel binding')
|
||||
|
||||
binding
|
||||
.command('get')
|
||||
.description('Get current user channel binding')
|
||||
.option('--type <type>', 'Channel type')
|
||||
.action(async (opts) => {
|
||||
const token = ensureAuth()
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
const resp = await apiRequest<ChannelUserBinding>(`/users/me/channels/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(resp, null, 2))
|
||||
})
|
||||
|
||||
binding
|
||||
.command('set')
|
||||
.description('Set current user channel binding')
|
||||
.option('--type <type>', 'Channel type (feishu)')
|
||||
.option('--open_id <open_id>')
|
||||
.option('--user_id <user_id>')
|
||||
.action(async (opts) => {
|
||||
const token = ensureAuth()
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
if (channelType !== 'feishu') {
|
||||
console.log(chalk.red(`Channel type ${channelType} is not supported by this command.`))
|
||||
process.exit(1)
|
||||
}
|
||||
const configPayload = await collectFeishuUserConfig(opts)
|
||||
const spinner = ora('Updating user binding...').start()
|
||||
try {
|
||||
await apiRequest(`/users/me/channels/${encodeURIComponent(channelType)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config: configPayload }),
|
||||
}, token)
|
||||
spinner.succeed('User binding updated')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update user binding')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
import readline from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
import { setupClient, client } from '../core/client'
|
||||
import { registerBotCommands } from './bot'
|
||||
import { registerChannelCommands } from './channel'
|
||||
import { streamChat } from './stream'
|
||||
import {
|
||||
readConfig,
|
||||
writeConfig,
|
||||
writeToken,
|
||||
clearToken,
|
||||
type TokenInfo,
|
||||
} from '../utils/store'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId } from './shared'
|
||||
|
||||
import {
|
||||
postAuthLogin,
|
||||
getUsersMe,
|
||||
getProviders,
|
||||
postProviders,
|
||||
getProvidersNameByName,
|
||||
deleteProvidersById,
|
||||
getModels,
|
||||
postModels,
|
||||
deleteModelsModelByModelId,
|
||||
type ProvidersGetResponse,
|
||||
type ModelsGetResponse,
|
||||
type ModelsAddRequest,
|
||||
type ScheduleSchedule,
|
||||
type ScheduleListResponse,
|
||||
} from '@memohai/sdk'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize SDK client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setupClient()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Program setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const program = new Command()
|
||||
program
|
||||
.name('memoh')
|
||||
.description('Memoh CLI')
|
||||
.version(packageJson.version)
|
||||
|
||||
registerBotCommands(program)
|
||||
registerChannelCommands(program)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model/Provider helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getModelId = (item: ModelsGetResponse) => item.model_id ?? ''
|
||||
const getProviderId = (item: ModelsGetResponse) => item.llm_provider_id ?? ''
|
||||
const getModelType = (item: ModelsGetResponse) => item.type ?? 'chat'
|
||||
const getModelInputModalities = (item: ModelsGetResponse) => item.input_modalities ?? ['text']
|
||||
|
||||
const ensureModelsReady = async () => {
|
||||
ensureAuth()
|
||||
const [chatResult, embeddingResult] = await Promise.all([
|
||||
getModels({ query: { type: 'chat' }, throwOnError: true }),
|
||||
getModels({ query: { type: 'embedding' }, throwOnError: true }),
|
||||
])
|
||||
const chatModels = chatResult.data ?? []
|
||||
const embeddingModels = embeddingResult.data ?? []
|
||||
if (!Array.isArray(chatModels) || chatModels.length === 0 ||
|
||||
!Array.isArray(embeddingModels) || embeddingModels.length === 0) {
|
||||
console.log(chalk.red('Model configuration incomplete.'))
|
||||
console.log(chalk.yellow('At least one chat model and one embedding model are required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const renderProvidersTable = (providers: ProvidersGetResponse[], models: ModelsGetResponse[]) => {
|
||||
const rows: string[][] = [['Provider', 'Base URL', 'Models']]
|
||||
for (const provider of providers) {
|
||||
const providerModels = models
|
||||
.filter(m => getProviderId(m) === provider.id)
|
||||
.map(m => `${getModelId(m)} (${getModelType(m)})`)
|
||||
rows.push([
|
||||
provider.name ?? '',
|
||||
provider.base_url ?? '',
|
||||
providerModels.join(', ') || '-',
|
||||
])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
const renderModelsTable = (models: ModelsGetResponse[], providers: ProvidersGetResponse[]) => {
|
||||
const providerMap = new Map(providers.map(p => [p.id, p.name]))
|
||||
const rows: string[][] = [['Model ID', 'Type', 'Provider', 'Input Modalities']]
|
||||
for (const item of models) {
|
||||
rows.push([
|
||||
getModelId(item),
|
||||
getModelType(item),
|
||||
providerMap.get(getProviderId(item)) ?? getProviderId(item),
|
||||
getModelInputModalities(item).join(', '),
|
||||
])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
const renderSchedulesTable = (items: ScheduleSchedule[]) => {
|
||||
const rows: string[][] = [['ID', 'Name', 'Pattern', 'Enabled', 'Max Calls', 'Current Calls', 'Command']]
|
||||
for (const item of items) {
|
||||
rows.push([
|
||||
item.id ?? '',
|
||||
item.name ?? '',
|
||||
item.pattern ?? '',
|
||||
item.enabled ? 'yes' : 'no',
|
||||
item.max_calls === null || item.max_calls === undefined ? '-' : String(item.max_calls),
|
||||
item.current_calls === undefined ? '-' : String(item.current_calls),
|
||||
item.command ?? '',
|
||||
])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
program
|
||||
.command('login')
|
||||
.description('Login')
|
||||
.action(async () => {
|
||||
const answers = await inquirer.prompt([
|
||||
{ type: 'input', name: 'username', message: 'Username:' },
|
||||
{ type: 'password', name: 'password', message: 'Password:' },
|
||||
])
|
||||
const spinner = ora('Logging in...').start()
|
||||
try {
|
||||
const { data } = await postAuthLogin({
|
||||
body: {
|
||||
username: answers.username,
|
||||
password: answers.password,
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
const tokenInfo: TokenInfo = {
|
||||
access_token: data.access_token ?? '',
|
||||
token_type: data.token_type ?? 'bearer',
|
||||
expires_at: data.expires_at ?? '',
|
||||
user_id: data.user_id ?? '',
|
||||
username: data.username,
|
||||
}
|
||||
writeToken(tokenInfo)
|
||||
spinner.succeed('Logged in')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Login failed')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('logout')
|
||||
.description('Logout')
|
||||
.action(() => {
|
||||
clearToken()
|
||||
console.log(chalk.green('Logged out'))
|
||||
})
|
||||
|
||||
program
|
||||
.command('whoami')
|
||||
.description('Show current user')
|
||||
.action(async () => {
|
||||
const token = ensureAuth()
|
||||
try {
|
||||
const { data } = await getUsersMe({ throwOnError: true })
|
||||
if (data.username) console.log(`username: ${data.username}`)
|
||||
if (data.display_name) console.log(`display_name: ${data.display_name}`)
|
||||
if (data.id) console.log(`user_id: ${data.id}`)
|
||||
if (data.role) console.log(`role: ${data.role}`)
|
||||
} catch {
|
||||
// Fallback to token info if API call fails
|
||||
if (token.username) console.log(`username: ${token.username}`)
|
||||
if (token.user_id) console.log(`user_id: ${token.user_id}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const configCmd = program
|
||||
.command('config')
|
||||
.description('Show or update current config')
|
||||
|
||||
configCmd.action(async () => {
|
||||
const config = readConfig()
|
||||
console.log(`host = "${config.host}"`)
|
||||
console.log(`port = ${config.port}`)
|
||||
})
|
||||
|
||||
configCmd
|
||||
.command('set')
|
||||
.description('Update config')
|
||||
.option('--host <host>')
|
||||
.option('--port <port>')
|
||||
.action(async (opts) => {
|
||||
const current = readConfig()
|
||||
let host = opts.host
|
||||
let port = opts.port ? Number.parseInt(opts.port, 10) : undefined
|
||||
|
||||
if (!host && !port) {
|
||||
const answers = await inquirer.prompt([
|
||||
{ type: 'input', name: 'host', message: 'Host:', default: current.host },
|
||||
{ type: 'input', name: 'port', message: 'Port:', default: current.port },
|
||||
])
|
||||
host = answers.host
|
||||
port = Number.parseInt(answers.port, 10)
|
||||
}
|
||||
|
||||
if (host) current.host = host
|
||||
if (port && !Number.isNaN(port)) current.port = port
|
||||
|
||||
writeConfig(current)
|
||||
console.log(chalk.green('Config updated'))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const provider = program.command('provider').description('Provider management')
|
||||
|
||||
provider
|
||||
.command('list')
|
||||
.description('List providers')
|
||||
.option('--provider <name>', 'Filter by provider name')
|
||||
.action(async (opts) => {
|
||||
ensureAuth()
|
||||
let providers: ProvidersGetResponse[]
|
||||
if (opts.provider) {
|
||||
const { data } = await getProvidersNameByName({
|
||||
path: { name: opts.provider },
|
||||
throwOnError: true,
|
||||
})
|
||||
providers = [data]
|
||||
} else {
|
||||
const { data } = await getProviders({ throwOnError: true })
|
||||
providers = data as ProvidersGetResponse[]
|
||||
}
|
||||
const { data: models } = await getModels({ throwOnError: true })
|
||||
console.log(renderProvidersTable(providers, models as ModelsGetResponse[]))
|
||||
})
|
||||
|
||||
provider
|
||||
.command('create')
|
||||
.description('Create provider')
|
||||
.option('--name <name>')
|
||||
.option('--base_url <url>')
|
||||
.option('--api_key <key>')
|
||||
.action(async (opts) => {
|
||||
ensureAuth()
|
||||
const questions = []
|
||||
if (!opts.name) questions.push({ type: 'input', name: 'name', message: 'Provider name:' })
|
||||
if (!opts.base_url) questions.push({ type: 'input', name: 'base_url', message: 'Base URL:' })
|
||||
if (!opts.api_key) questions.push({ type: 'password', name: 'api_key', message: 'API key:' })
|
||||
const answers = questions.length ? await inquirer.prompt(questions) : {}
|
||||
const spinner = ora('Creating provider...').start()
|
||||
try {
|
||||
await postProviders({
|
||||
body: {
|
||||
name: opts.name ?? answers.name,
|
||||
base_url: opts.base_url ?? answers.base_url,
|
||||
api_key: opts.api_key ?? answers.api_key,
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Provider created')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to create provider')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
provider
|
||||
.command('delete')
|
||||
.description('Delete provider')
|
||||
.option('--provider <name>', 'Provider name')
|
||||
.action(async (opts) => {
|
||||
ensureAuth()
|
||||
if (!opts.provider) {
|
||||
console.log(chalk.red('Provider name is required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const { data: providerInfo } = await getProvidersNameByName({
|
||||
path: { name: opts.provider },
|
||||
throwOnError: true,
|
||||
})
|
||||
const spinner = ora('Deleting provider...').start()
|
||||
try {
|
||||
await deleteProvidersById({
|
||||
path: { id: providerInfo.id! },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Provider deleted')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to delete provider')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const model = program.command('model').description('Model management')
|
||||
|
||||
model
|
||||
.command('list')
|
||||
.description('List models')
|
||||
.action(async () => {
|
||||
ensureAuth()
|
||||
const [modelsResult, providersResult] = await Promise.all([
|
||||
getModels({ throwOnError: true }),
|
||||
getProviders({ throwOnError: true }),
|
||||
])
|
||||
console.log(renderModelsTable(
|
||||
modelsResult.data as ModelsGetResponse[],
|
||||
providersResult.data as ProvidersGetResponse[],
|
||||
))
|
||||
})
|
||||
|
||||
model
|
||||
.command('create')
|
||||
.description('Create model')
|
||||
.option('--model_id <model_id>')
|
||||
.option('--name <name>')
|
||||
.option('--provider <provider>')
|
||||
.option('--client_type <client_type>', 'Client type: openai-responses, openai-completions, anthropic-messages, google-generative-ai')
|
||||
.option('--type <type>')
|
||||
.option('--dimensions <dimensions>')
|
||||
.option('--multimodal', 'Is multimodal')
|
||||
.action(async (opts) => {
|
||||
ensureAuth()
|
||||
const { data: providerList } = await getProviders({ throwOnError: true })
|
||||
const providers = providerList as ProvidersGetResponse[]
|
||||
let provider = providers.find(p => p.name === opts.provider)
|
||||
if (!provider) {
|
||||
const answer = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'provider',
|
||||
message: 'Select provider:',
|
||||
choices: providers.map(p => p.name),
|
||||
}])
|
||||
provider = providers.find(p => p.name === answer.provider)
|
||||
}
|
||||
if (!provider) {
|
||||
console.log(chalk.red('Provider not found.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const questions = []
|
||||
if (!opts.model_id) questions.push({ type: 'input', name: 'model_id', message: 'Model ID (e.g. gpt-4):' })
|
||||
if (!opts.type) questions.push({ type: 'list', name: 'type', message: 'Model type:', choices: ['chat', 'embedding'] })
|
||||
const answers = questions.length ? await inquirer.prompt(questions) : {}
|
||||
const modelId = opts.model_id ?? answers.model_id
|
||||
const modelType = opts.type ?? answers.type
|
||||
let clientType = opts.client_type
|
||||
if (modelType === 'chat' && !clientType) {
|
||||
const ctAnswer = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'client_type',
|
||||
message: 'Client type:',
|
||||
choices: ['openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai'],
|
||||
}])
|
||||
clientType = ctAnswer.client_type
|
||||
}
|
||||
let dimensions = opts.dimensions ? Number.parseInt(opts.dimensions, 10) : undefined
|
||||
if (modelType === 'embedding' && (!dimensions || Number.isNaN(dimensions))) {
|
||||
const dimAnswer = await inquirer.prompt([{
|
||||
type: 'input',
|
||||
name: 'dimensions',
|
||||
message: 'Embedding dimensions (e.g. 1536):',
|
||||
}])
|
||||
dimensions = Number.parseInt(dimAnswer.dimensions, 10)
|
||||
}
|
||||
if (modelType === 'embedding' && (!dimensions || Number.isNaN(dimensions) || dimensions <= 0)) {
|
||||
console.log(chalk.red('Embedding models require a valid dimensions value.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const inputModalities = opts.multimodal ? ['text', 'image'] : ['text']
|
||||
const spinner = ora('Creating model...').start()
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model_id: modelId,
|
||||
name: opts.name ?? modelId,
|
||||
llm_provider_id: provider.id,
|
||||
input_modalities: inputModalities,
|
||||
type: modelType,
|
||||
dimensions,
|
||||
}
|
||||
if (modelType === 'chat' && clientType) {
|
||||
body.client_type = clientType
|
||||
}
|
||||
await postModels({ body: body as ModelsAddRequest, throwOnError: true })
|
||||
spinner.succeed('Model created')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to create model')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
model
|
||||
.command('delete')
|
||||
.description('Delete model')
|
||||
.option('--model <model>')
|
||||
.action(async (opts) => {
|
||||
ensureAuth()
|
||||
if (!opts.model) {
|
||||
console.log(chalk.red('Model name is required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const spinner = ora('Deleting model...').start()
|
||||
try {
|
||||
await deleteModelsModelByModelId({
|
||||
path: { modelId: opts.model },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Model deleted')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to delete model')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule commands (uses raw client due to untyped bot_id path param)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const schedule = program.command('schedule').description('Schedule management')
|
||||
.option('--bot <id>', 'Bot ID (required for schedule operations)')
|
||||
|
||||
const resolveScheduleBotId = async (opts: { bot?: string }) => {
|
||||
return await resolveBotId(opts.bot)
|
||||
}
|
||||
|
||||
schedule
|
||||
.command('list')
|
||||
.description('List schedules')
|
||||
.action(async () => {
|
||||
ensureAuth()
|
||||
const botId = await resolveScheduleBotId(schedule.opts())
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule`,
|
||||
throwOnError: true,
|
||||
})
|
||||
const resp = data as ScheduleListResponse
|
||||
if (!resp.items?.length) {
|
||||
console.log(chalk.yellow('No schedules found.'))
|
||||
return
|
||||
}
|
||||
console.log(renderSchedulesTable(resp.items))
|
||||
})
|
||||
|
||||
schedule
|
||||
.command('get')
|
||||
.description('Get schedule')
|
||||
.argument('<id>')
|
||||
.action(async (id) => {
|
||||
ensureAuth()
|
||||
const botId = await resolveScheduleBotId(schedule.opts())
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`,
|
||||
throwOnError: true,
|
||||
})
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
})
|
||||
|
||||
schedule
|
||||
.command('create')
|
||||
.description('Create schedule')
|
||||
.option('--name <name>')
|
||||
.option('--description <description>')
|
||||
.option('--pattern <pattern>')
|
||||
.option('--command <command>')
|
||||
.option('--max_calls <max_calls>')
|
||||
.option('--enabled')
|
||||
.option('--disabled')
|
||||
.action(async (opts) => {
|
||||
if (opts.enabled && opts.disabled) {
|
||||
console.log(chalk.red('Use only one of --enabled or --disabled.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const questions = []
|
||||
if (!opts.name) questions.push({ type: 'input', name: 'name', message: 'Name:' })
|
||||
if (!opts.description) questions.push({ type: 'input', name: 'description', message: 'Description:' })
|
||||
if (!opts.pattern) questions.push({ type: 'input', name: 'pattern', message: 'Cron pattern:' })
|
||||
if (!opts.command) questions.push({ type: 'input', name: 'command', message: 'Command:' })
|
||||
if (opts.max_calls === undefined) {
|
||||
questions.push({
|
||||
type: 'input',
|
||||
name: 'max_calls',
|
||||
message: 'Max calls (optional, empty for unlimited):',
|
||||
default: '',
|
||||
})
|
||||
}
|
||||
const answers = questions.length ? await inquirer.prompt(questions) : {}
|
||||
const maxCallsInput = opts.max_calls ?? answers.max_calls
|
||||
let maxCalls: number | undefined
|
||||
if (maxCallsInput !== undefined && String(maxCallsInput).trim() !== '') {
|
||||
const parsed = Number.parseInt(String(maxCallsInput), 10)
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
console.log(chalk.red('max_calls must be a positive integer.'))
|
||||
process.exit(1)
|
||||
}
|
||||
maxCalls = parsed
|
||||
}
|
||||
const payload = {
|
||||
name: opts.name ?? answers.name,
|
||||
description: opts.description ?? answers.description,
|
||||
pattern: opts.pattern ?? answers.pattern,
|
||||
command: opts.command ?? answers.command,
|
||||
max_calls: maxCalls !== undefined ? { set: true, value: maxCalls } : undefined,
|
||||
enabled: opts.enabled ? true : (opts.disabled ? false : undefined),
|
||||
}
|
||||
ensureAuth()
|
||||
const botId = await resolveScheduleBotId(schedule.opts())
|
||||
const spinner = ora('Creating schedule...').start()
|
||||
try {
|
||||
await client.post({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule`,
|
||||
body: payload,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Schedule created')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to create schedule')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
schedule
|
||||
.command('update')
|
||||
.description('Update schedule')
|
||||
.argument('<id>')
|
||||
.option('--name <name>')
|
||||
.option('--description <description>')
|
||||
.option('--pattern <pattern>')
|
||||
.option('--command <command>')
|
||||
.option('--max_calls <max_calls>')
|
||||
.option('--enabled')
|
||||
.option('--disabled')
|
||||
.action(async (id, opts) => {
|
||||
if (opts.enabled && opts.disabled) {
|
||||
console.log(chalk.red('Use only one of --enabled or --disabled.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (opts.name) payload.name = opts.name
|
||||
if (opts.description) payload.description = opts.description
|
||||
if (opts.pattern) payload.pattern = opts.pattern
|
||||
if (opts.command) payload.command = opts.command
|
||||
if (opts.max_calls !== undefined) {
|
||||
const parsed = Number.parseInt(String(opts.max_calls), 10)
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
console.log(chalk.red('max_calls must be a positive integer.'))
|
||||
process.exit(1)
|
||||
}
|
||||
payload.max_calls = { set: true, value: parsed }
|
||||
}
|
||||
if (opts.enabled) payload.enabled = true
|
||||
if (opts.disabled) payload.enabled = false
|
||||
if (Object.keys(payload).length === 0) {
|
||||
console.log(chalk.red('No updates provided.'))
|
||||
process.exit(1)
|
||||
}
|
||||
ensureAuth()
|
||||
const botId = await resolveScheduleBotId(schedule.opts())
|
||||
const spinner = ora('Updating schedule...').start()
|
||||
try {
|
||||
await client.put({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`,
|
||||
body: payload,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Schedule updated')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update schedule')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
schedule
|
||||
.command('toggle')
|
||||
.description('Enable/disable schedule')
|
||||
.argument('<id>')
|
||||
.action(async (id) => {
|
||||
ensureAuth()
|
||||
const botId = await resolveScheduleBotId(schedule.opts())
|
||||
const { data: current } = await client.get({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`,
|
||||
throwOnError: true,
|
||||
})
|
||||
const currentSchedule = current as ScheduleSchedule
|
||||
const spinner = ora('Updating schedule...').start()
|
||||
try {
|
||||
await client.put({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`,
|
||||
body: { enabled: !currentSchedule.enabled },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed(`Schedule ${currentSchedule.enabled ? 'disabled' : 'enabled'}`)
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update schedule')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
schedule
|
||||
.command('delete')
|
||||
.description('Delete schedule')
|
||||
.argument('<id>')
|
||||
.action(async (id) => {
|
||||
ensureAuth()
|
||||
const botId = await resolveScheduleBotId(schedule.opts())
|
||||
const spinner = ora('Deleting schedule...').start()
|
||||
try {
|
||||
await client.delete({
|
||||
url: `/bots/${encodeURIComponent(botId)}/schedule/${encodeURIComponent(id)}`,
|
||||
throwOnError: true,
|
||||
})
|
||||
spinner.succeed('Schedule deleted')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to delete schedule')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default action: interactive chat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
program
|
||||
.option('--bot <id>', 'Bot id to chat with')
|
||||
.action(async () => {
|
||||
await ensureModelsReady()
|
||||
ensureAuth()
|
||||
const botId = await resolveBotId(program.opts().bot)
|
||||
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
|
||||
|
||||
while (true) {
|
||||
const line = (await rl.question(chalk.cyan('> '))).trim()
|
||||
if (!line) {
|
||||
if (input.readableEnded) break
|
||||
continue
|
||||
}
|
||||
if (line.toLowerCase() === 'exit') {
|
||||
break
|
||||
}
|
||||
await streamChat(line, botId)
|
||||
}
|
||||
rl.close()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
program
|
||||
.command('version')
|
||||
.description('Show version information')
|
||||
.action(() => {
|
||||
console.log(`Memoh CLI v${packageJson.version}`)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TUI command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
program
|
||||
.command('tui')
|
||||
.description('Terminal UI chat session')
|
||||
.option('--bot <id>', 'Bot id to chat with')
|
||||
.action(async (opts: { bot?: string }) => {
|
||||
await ensureModelsReady()
|
||||
ensureAuth()
|
||||
const botId = await resolveBotId(opts.bot)
|
||||
await runTui(botId)
|
||||
})
|
||||
|
||||
program.parseAsync(process.argv)
|
||||
|
||||
const runTui = async (botId: string) => {
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`TUI session (line mode) with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
|
||||
while (true) {
|
||||
const line = (await rl.question(chalk.cyan('> '))).trim()
|
||||
if (!line) {
|
||||
if (input.readableEnded) break
|
||||
continue
|
||||
}
|
||||
if (line.toLowerCase() === 'exit') {
|
||||
break
|
||||
}
|
||||
await streamChat(line, botId)
|
||||
}
|
||||
rl.close()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
|
||||
import { getBots, type BotsBot } from '@memohai/sdk'
|
||||
import { readToken, type TokenInfo } from '../utils/store'
|
||||
|
||||
export type BotSummary = BotsBot
|
||||
|
||||
export const ensureAuth = (): TokenInfo => {
|
||||
const token = readToken()
|
||||
if (!token?.access_token) {
|
||||
console.log(chalk.red('Not logged in. Run `memoh login` first.'))
|
||||
process.exit(1)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
export const getErrorMessage = (err: unknown) => {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const value = (err as { message?: unknown }).message
|
||||
if (typeof value === 'string') return value
|
||||
}
|
||||
return 'Unknown error'
|
||||
}
|
||||
|
||||
export const fetchBots = async () => {
|
||||
const { data } = await getBots({ throwOnError: true })
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export const resolveBotId = async (preset?: string) => {
|
||||
if (preset && preset.trim()) {
|
||||
return preset.trim()
|
||||
}
|
||||
const spinner = ora('Fetching bots...').start()
|
||||
try {
|
||||
const bots = await fetchBots()
|
||||
spinner.stop()
|
||||
if (bots.length === 0) {
|
||||
console.log(chalk.yellow('No bots found. Please create a bot first.'))
|
||||
process.exit(0)
|
||||
}
|
||||
const { botId } = await inquirer.prompt<{ botId: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'botId',
|
||||
message: 'Select a bot:',
|
||||
choices: bots.map(bot => ({
|
||||
name: `${bot.display_name || bot.id} ${chalk.gray(bot.type ?? '')}`,
|
||||
value: bot.id,
|
||||
})),
|
||||
},
|
||||
])
|
||||
return botId
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(`Failed to fetch bots: ${getErrorMessage(err)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
import chalk from 'chalk'
|
||||
import { client } from '@memohai/sdk/client'
|
||||
import { postBotsByBotIdCliMessages } from '@memohai/sdk'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE stream types (aligned with frontend useChat.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StreamEvent {
|
||||
type?: string
|
||||
delta?: string
|
||||
toolName?: string
|
||||
input?: unknown
|
||||
result?: unknown
|
||||
error?: string
|
||||
message?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE parsing (directly from frontend useChat.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read an SSE stream line-by-line, calling onData for each `data:` payload.
|
||||
* Handles standard SSE format (events separated by double newlines).
|
||||
*/
|
||||
async function readSSEStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onData: (payload: string) => void,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const chunks = buffer.split('\n\n')
|
||||
buffer = chunks.pop() ?? ''
|
||||
|
||||
for (const chunk of chunks) {
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line.startsWith('data:')) continue
|
||||
const payload = line.replace(/^data:\s*/, '').trim()
|
||||
if (payload && payload !== '[DONE]') onData(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining buffer
|
||||
if (buffer.trim()) {
|
||||
for (const line of buffer.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('data:')) continue
|
||||
const payload = trimmed.replace(/^data:\s*/, '').trim()
|
||||
if (payload && payload !== '[DONE]') onData(payload)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw SSE payload string into a StreamEvent.
|
||||
* Handles double-encoded JSON and plain text deltas.
|
||||
* (directly from frontend useChat.ts)
|
||||
*/
|
||||
function parseStreamPayload(payload: string): StreamEvent | null {
|
||||
let current: unknown = payload
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (typeof current !== 'string') break
|
||||
const raw = current.trim()
|
||||
if (!raw || raw === '[DONE]') return null
|
||||
try {
|
||||
current = JSON.parse(raw)
|
||||
} catch {
|
||||
return { type: 'text_delta', delta: raw } as StreamEvent
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof current === 'string') {
|
||||
return { type: 'text_delta', delta: current.trim() } as StreamEvent
|
||||
}
|
||||
if (current && typeof current === 'object') {
|
||||
return normalizeStreamEvent(current as Record<string, unknown>)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const LEGACY_STREAM_EVENT_TYPES = new Set<string>([
|
||||
'text_start',
|
||||
'text_delta',
|
||||
'text_end',
|
||||
'reasoning_start',
|
||||
'reasoning_delta',
|
||||
'reasoning_end',
|
||||
'tool_call_start',
|
||||
'tool_call_end',
|
||||
'attachment_delta',
|
||||
'agent_start',
|
||||
'agent_end',
|
||||
'processing_started',
|
||||
'processing_completed',
|
||||
'processing_failed',
|
||||
'error',
|
||||
])
|
||||
|
||||
function normalizeStreamEvent(raw: Record<string, unknown>): StreamEvent | null {
|
||||
const eventType = String(raw.type ?? '').trim().toLowerCase()
|
||||
if (!eventType) return null
|
||||
if (LEGACY_STREAM_EVENT_TYPES.has(eventType)) {
|
||||
return raw as StreamEvent
|
||||
}
|
||||
switch (eventType) {
|
||||
case 'status': {
|
||||
const status = String(raw.status ?? '').trim().toLowerCase()
|
||||
if (status === 'started') return { type: 'processing_started' }
|
||||
if (status === 'completed') return { type: 'processing_completed' }
|
||||
if (status === 'failed') {
|
||||
const err = String(raw.error ?? '').trim()
|
||||
return { type: 'processing_failed', error: err, message: err }
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'delta': {
|
||||
const delta = String(raw.delta ?? '')
|
||||
const phase = String(raw.phase ?? '').trim().toLowerCase()
|
||||
if (phase === 'reasoning') {
|
||||
return { type: 'reasoning_delta', delta }
|
||||
}
|
||||
return { type: 'text_delta', delta }
|
||||
}
|
||||
case 'phase_start': {
|
||||
const phase = String(raw.phase ?? '').trim().toLowerCase()
|
||||
if (phase === 'reasoning') return { type: 'reasoning_start' }
|
||||
if (phase === 'text') return { type: 'text_start' }
|
||||
return null
|
||||
}
|
||||
case 'phase_end': {
|
||||
const phase = String(raw.phase ?? '').trim().toLowerCase()
|
||||
if (phase === 'reasoning') return { type: 'reasoning_end' }
|
||||
if (phase === 'text') return { type: 'text_end' }
|
||||
return null
|
||||
}
|
||||
case 'tool_call_start':
|
||||
case 'tool_call_end': {
|
||||
const toolCall = (raw.tool_call && typeof raw.tool_call === 'object')
|
||||
? raw.tool_call as Record<string, unknown>
|
||||
: {}
|
||||
return {
|
||||
type: eventType,
|
||||
toolName: String(toolCall.name ?? ''),
|
||||
toolCallId: String(toolCall.call_id ?? ''),
|
||||
input: toolCall.input,
|
||||
result: toolCall.result,
|
||||
} as StreamEvent
|
||||
}
|
||||
case 'attachment': {
|
||||
const attachments = Array.isArray(raw.attachments)
|
||||
? raw.attachments as Array<Record<string, unknown>>
|
||||
: []
|
||||
if (!attachments.length) return null
|
||||
return { type: 'attachment_delta', attachments } as StreamEvent
|
||||
}
|
||||
case 'processing_started':
|
||||
case 'processing_completed':
|
||||
case 'agent_start':
|
||||
case 'agent_end':
|
||||
return { type: eventType } as StreamEvent
|
||||
case 'processing_failed': {
|
||||
const err = String(raw.error ?? raw.message ?? '').trim()
|
||||
return { type: 'processing_failed', error: err, message: err } as StreamEvent
|
||||
}
|
||||
case 'error': {
|
||||
const err = String(raw.error ?? raw.message ?? 'Stream error').trim()
|
||||
return { type: 'error', error: err, message: err } as StreamEvent
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool display configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ToolDisplayMode = 'inline' | 'expanded'
|
||||
|
||||
interface ToolDisplayConfig {
|
||||
mode: ToolDisplayMode
|
||||
expandParam?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const TOOL_DISPLAY: Record<string, ToolDisplayConfig> = {
|
||||
exec: { mode: 'expanded', label: 'exec' },
|
||||
write: { mode: 'expanded', expandParam: 'content', label: 'write' },
|
||||
}
|
||||
|
||||
const getToolDisplay = (toolName: string): ToolDisplayConfig => {
|
||||
return TOOL_DISPLAY[toolName] ?? { mode: 'inline' }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOX_WIDTH = 60
|
||||
|
||||
const extractExecCommand = (toolInput: unknown): string => {
|
||||
if (!toolInput || typeof toolInput !== 'object') return ''
|
||||
const input = toolInput as Record<string, unknown>
|
||||
const command = typeof input.command === 'string' ? input.command : ''
|
||||
const args = Array.isArray(input.args) ? input.args.map(String) : []
|
||||
if (/^(bash|sh|zsh)$/.test(command) && args.length >= 2) {
|
||||
const flag = args[0]
|
||||
if (flag === '-c' || flag === '-lc') {
|
||||
return args.slice(1).join(' ')
|
||||
}
|
||||
}
|
||||
return [command, ...args].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const formatExecCall = (toolInput: unknown) => {
|
||||
const cmd = extractExecCommand(toolInput)
|
||||
return chalk.dim(' ▶ ') + chalk.white('$ ') + chalk.bold.white(cmd)
|
||||
}
|
||||
|
||||
const extractEditInput = (toolInput: unknown) => {
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return { path: '', oldText: '', newText: '' }
|
||||
}
|
||||
const input = toolInput as Record<string, unknown>
|
||||
const path = typeof input.path === 'string' ? input.path : ''
|
||||
const oldText =
|
||||
typeof input.old_text === 'string'
|
||||
? input.old_text
|
||||
: typeof input.oldText === 'string'
|
||||
? input.oldText
|
||||
: ''
|
||||
const newText =
|
||||
typeof input.new_text === 'string'
|
||||
? input.new_text
|
||||
: typeof input.newText === 'string'
|
||||
? input.newText
|
||||
: ''
|
||||
return { path, oldText, newText }
|
||||
}
|
||||
|
||||
const countLines = (text: string) => (text ? text.split('\n').length : 0)
|
||||
|
||||
const pushDetailBlock = (lines: string[], title: string, content: string) => {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim(title))
|
||||
const detailLines = content ? content.split('\n') : []
|
||||
if (!detailLines.length) {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim('∅'))
|
||||
return
|
||||
}
|
||||
const maxLines = 12
|
||||
const shown = detailLines.slice(0, maxLines)
|
||||
for (const dl of shown) {
|
||||
const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl
|
||||
lines.push(chalk.cyan('│ ') + chalk.white(truncated))
|
||||
}
|
||||
if (detailLines.length > maxLines) {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`))
|
||||
}
|
||||
}
|
||||
|
||||
const formatEditCall = (toolInput: unknown) => {
|
||||
const { path, oldText, newText } = extractEditInput(toolInput)
|
||||
const oldLines = countLines(oldText)
|
||||
const newLines = countLines(newText)
|
||||
const summary = ` path: ${path || '(unknown)'} · old: ${oldLines} lines · new: ${newLines} lines`
|
||||
|
||||
const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐'
|
||||
const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘'
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(chalk.cyan(topBorder))
|
||||
lines.push(chalk.cyan('│ ') + chalk.bold.white('edit') + chalk.gray(summary))
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4)))
|
||||
pushDetailBlock(lines, 'old_text', oldText)
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4)))
|
||||
pushDetailBlock(lines, 'new_text', newText)
|
||||
lines.push(chalk.cyan(botBorder))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const unwrapToolResult = (result: unknown): Record<string, unknown> | null => {
|
||||
if (!result) return null
|
||||
const extractFromContentBlocks = (arr: unknown[]): Record<string, unknown> | null => {
|
||||
for (const block of arr) {
|
||||
if (block && typeof block === 'object') {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text' && typeof b.text === 'string') {
|
||||
try { return JSON.parse(b.text) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (Array.isArray(result)) return extractFromContentBlocks(result)
|
||||
if (typeof result === 'object') {
|
||||
const obj = result as Record<string, unknown>
|
||||
if (Array.isArray(obj.content)) {
|
||||
const extracted = extractFromContentBlocks(obj.content)
|
||||
if (extracted) return extracted
|
||||
}
|
||||
return obj
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
try { return JSON.parse(result) } catch { /* ignore */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatExecResult = (result: unknown) => {
|
||||
const r = unwrapToolResult(result)
|
||||
if (!r) return chalk.dim(' ╰─ done')
|
||||
const exitCode = typeof r.exit_code === 'number' ? r.exit_code : (r.ok ? 0 : 1)
|
||||
const ok = exitCode === 0
|
||||
const stdout = typeof r.stdout === 'string' ? r.stdout.trim() : ''
|
||||
const stderr = typeof r.stderr === 'string' ? r.stderr.trim() : ''
|
||||
const lines: string[] = []
|
||||
lines.push(chalk.dim(' ╰─ ') + (ok ? chalk.green(`✓ exit ${exitCode}`) : chalk.red(`✗ exit ${exitCode}`)))
|
||||
const output = ok ? stdout : (stderr || stdout)
|
||||
if (output) {
|
||||
const outputLines = output.split('\n')
|
||||
const maxLines = 8
|
||||
const shown = outputLines.slice(0, maxLines)
|
||||
for (const ol of shown) {
|
||||
const truncated = ol.length > 72 ? ol.slice(0, 69) + '...' : ol
|
||||
lines.push(chalk.dim(' ') + (ok ? chalk.white(truncated) : chalk.yellow(truncated)))
|
||||
}
|
||||
if (outputLines.length > maxLines) {
|
||||
lines.push(chalk.dim(` ... (${outputLines.length - maxLines} more lines)`))
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const formatToolCallInline = (toolName: string, toolInput: unknown) => {
|
||||
let params = ''
|
||||
if (toolInput && typeof toolInput === 'object') {
|
||||
const entries = Object.entries(toolInput as Record<string, unknown>)
|
||||
params = entries
|
||||
.map(([k, v]) => {
|
||||
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
const short = s.length > 40 ? s.slice(0, 37) + '...' : s
|
||||
return `${k}=${short}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
return chalk.dim(` ◆ ${toolName}`) + (params ? chalk.dim(`(${params})`) : '')
|
||||
}
|
||||
|
||||
const formatToolCallExpanded = (config: ToolDisplayConfig, toolName: string, toolInput: unknown) => {
|
||||
const label = config.label ?? toolName
|
||||
const inputObj = (toolInput && typeof toolInput === 'object' ? toolInput : {}) as Record<string, unknown>
|
||||
const summaryParts: string[] = []
|
||||
for (const [k, v] of Object.entries(inputObj)) {
|
||||
if (k === config.expandParam) continue
|
||||
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
summaryParts.push(`${k}: ${s.length > 50 ? s.slice(0, 47) + '...' : s}`)
|
||||
}
|
||||
const summary = summaryParts.length ? ' ' + summaryParts.join(', ') : ''
|
||||
let detail = ''
|
||||
if (config.expandParam && config.expandParam in inputObj) {
|
||||
const raw = inputObj[config.expandParam]
|
||||
if (typeof raw === 'string') detail = raw
|
||||
else if (Array.isArray(raw)) detail = raw.join(' ')
|
||||
else detail = JSON.stringify(raw, null, 2)
|
||||
}
|
||||
const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐'
|
||||
const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘'
|
||||
const lines: string[] = []
|
||||
lines.push(chalk.cyan(topBorder))
|
||||
lines.push(chalk.cyan('│ ') + chalk.bold.white(label) + chalk.gray(summary))
|
||||
if (detail) {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4)))
|
||||
const detailLines = detail.split('\n')
|
||||
const maxLines = 20
|
||||
const shown = detailLines.slice(0, maxLines)
|
||||
for (const dl of shown) {
|
||||
const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl
|
||||
lines.push(chalk.cyan('│ ') + chalk.white(truncated))
|
||||
}
|
||||
if (detailLines.length > maxLines) {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`))
|
||||
}
|
||||
}
|
||||
lines.push(chalk.cyan(botBorder))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const formatToolResult = (toolName: string, result: unknown) => {
|
||||
if (toolName === 'exec') return formatExecResult(result)
|
||||
const config = getToolDisplay(toolName)
|
||||
if (config.mode === 'expanded' || toolName === 'edit') {
|
||||
const r = unwrapToolResult(result)
|
||||
if (r) {
|
||||
if ('ok' in r) {
|
||||
return chalk.dim(' ╰─ ') + (r.ok ? chalk.green('✓ ok') : chalk.red('✗ failed'))
|
||||
}
|
||||
}
|
||||
return chalk.dim(' ╰─ done')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handler for terminal display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleStreamEvent(event: StreamEvent): boolean {
|
||||
const type = (event.type ?? '').toLowerCase()
|
||||
// Track whether text has been written without a trailing newline
|
||||
return handleStreamEventInner(type, event)
|
||||
}
|
||||
|
||||
let _printedText = false
|
||||
|
||||
function handleStreamEventInner(type: string, event: StreamEvent): boolean {
|
||||
switch (type) {
|
||||
case 'text_start':
|
||||
break
|
||||
|
||||
case 'text_delta':
|
||||
if (typeof event.delta === 'string') {
|
||||
process.stdout.write(event.delta)
|
||||
_printedText = true
|
||||
}
|
||||
break
|
||||
|
||||
case 'text_end':
|
||||
if (_printedText) {
|
||||
process.stdout.write('\n')
|
||||
_printedText = false
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_call_start': {
|
||||
if (_printedText) {
|
||||
process.stdout.write('\n')
|
||||
_printedText = false
|
||||
}
|
||||
const toolName = event.toolName as string
|
||||
const toolInput = event.input
|
||||
if (toolName === 'exec') {
|
||||
console.log(formatExecCall(toolInput))
|
||||
} else if (toolName === 'edit') {
|
||||
console.log(formatEditCall(toolInput))
|
||||
} else {
|
||||
const displayConfig = getToolDisplay(toolName)
|
||||
if (displayConfig.mode === 'expanded') {
|
||||
console.log(formatToolCallExpanded(displayConfig, toolName, toolInput))
|
||||
} else {
|
||||
console.log(formatToolCallInline(toolName, toolInput))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_call_end': {
|
||||
const toolName = event.toolName as string
|
||||
const result = event.result
|
||||
const resultLine = formatToolResult(toolName, result)
|
||||
if (resultLine) console.log(resultLine)
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning_start':
|
||||
if (_printedText) {
|
||||
process.stdout.write('\n')
|
||||
_printedText = false
|
||||
}
|
||||
process.stdout.write(chalk.dim(' 💭 '))
|
||||
break
|
||||
|
||||
case 'reasoning_delta':
|
||||
if (typeof event.delta === 'string') {
|
||||
process.stdout.write(chalk.dim(event.delta))
|
||||
_printedText = true
|
||||
}
|
||||
break
|
||||
|
||||
case 'reasoning_end':
|
||||
if (_printedText) {
|
||||
process.stdout.write('\n')
|
||||
_printedText = false
|
||||
}
|
||||
break
|
||||
|
||||
case 'error': {
|
||||
const errMsg = typeof event.message === 'string'
|
||||
? event.message
|
||||
: typeof event.error === 'string'
|
||||
? event.error
|
||||
: 'Stream error'
|
||||
console.log(chalk.red(`Error: ${errMsg}`))
|
||||
break
|
||||
}
|
||||
|
||||
case 'processing_started':
|
||||
case 'processing_completed':
|
||||
case 'processing_failed':
|
||||
case 'agent_start':
|
||||
case 'agent_end':
|
||||
break
|
||||
|
||||
default: {
|
||||
// Fallback: try to extract text (aligned with frontend extractFallbackText)
|
||||
if (typeof event.delta === 'string') {
|
||||
process.stdout.write(event.delta)
|
||||
_printedText = true
|
||||
} else if (typeof (event as Record<string, unknown>).text === 'string') {
|
||||
process.stdout.write((event as Record<string, unknown>).text as string)
|
||||
_printedText = true
|
||||
} else if (typeof (event as Record<string, unknown>).content === 'string') {
|
||||
process.stdout.write((event as Record<string, unknown>).content as string)
|
||||
_printedText = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream chat
|
||||
// CLI channel flow:
|
||||
// 1) open SSE subscription at /bots/{bot_id}/cli/stream
|
||||
// 2) post message to /bots/{bot_id}/cli/messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const streamChat = async (query: string, botId: string) => {
|
||||
_printedText = false
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const { data: body } = await client.get({
|
||||
url: '/bots/{bot_id}/cli/stream',
|
||||
path: { bot_id: botId },
|
||||
parseAs: 'stream',
|
||||
signal: controller.signal,
|
||||
throwOnError: true,
|
||||
}) as { data: ReadableStream<Uint8Array> }
|
||||
|
||||
if (!body) {
|
||||
console.log(chalk.red('No response body'))
|
||||
return false
|
||||
}
|
||||
|
||||
let completed = false
|
||||
let failedMessage = ''
|
||||
const streamTask = readSSEStream(body, (payload) => {
|
||||
const event = parseStreamPayload(payload)
|
||||
if (!event) return
|
||||
handleStreamEvent(event)
|
||||
const type = (event.type ?? '').toLowerCase()
|
||||
if (type === 'processing_completed') {
|
||||
completed = true
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
if (type === 'processing_failed' || type === 'error') {
|
||||
const msg = typeof event.message === 'string'
|
||||
? event.message
|
||||
: typeof event.error === 'string'
|
||||
? event.error
|
||||
: 'Stream error'
|
||||
failedMessage = msg
|
||||
controller.abort()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
await postBotsByBotIdCliMessages({
|
||||
path: { bot_id: botId },
|
||||
body: { message: { text: query } },
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
await streamTask
|
||||
|
||||
if (_printedText) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
if (failedMessage) {
|
||||
console.log(chalk.red(`Stream error: ${failedMessage}`))
|
||||
return false
|
||||
}
|
||||
if (!completed) {
|
||||
console.log(chalk.red('Stream ended before completion'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
if (_printedText) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
console.log(chalk.red(`Stream error: ${msg}`))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { client } from '@memohai/sdk/client'
|
||||
import { readConfig, readToken, getBaseURL } from '../utils/store'
|
||||
|
||||
/**
|
||||
* Configure the SDK client with base URL and auth interceptor.
|
||||
* Call this once at CLI startup (before any API calls).
|
||||
*/
|
||||
export function setupClient() {
|
||||
const config = readConfig()
|
||||
client.setConfig({ baseUrl: getBaseURL(config) })
|
||||
|
||||
// Add auth token to every request (read lazily from store)
|
||||
client.interceptors.request.use((request) => {
|
||||
const token = readToken()
|
||||
if (token?.access_token) {
|
||||
request.headers.set('Authorization', `Bearer ${token.access_token}`)
|
||||
}
|
||||
return request
|
||||
})
|
||||
}
|
||||
|
||||
export { client }
|
||||
@@ -1 +0,0 @@
|
||||
export * from './client'
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './core/index'
|
||||
export * from './types/index'
|
||||
export * from './utils/index'
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export type { CliConfig, TokenInfo } from '../utils/store'
|
||||
|
||||
// Re-export commonly used SDK types for convenience
|
||||
export type {
|
||||
BotsBot,
|
||||
BotsCreateBotRequest,
|
||||
BotsListBotsResponse,
|
||||
BotsUpdateBotRequest,
|
||||
HandlersLoginResponse,
|
||||
HandlersChannelMeta,
|
||||
ChannelChannelConfig,
|
||||
ChannelChannelIdentityBinding,
|
||||
ModelsGetResponse,
|
||||
ModelsModelType,
|
||||
ProvidersGetResponse,
|
||||
ScheduleListResponse,
|
||||
ScheduleSchedule,
|
||||
SettingsSettings,
|
||||
AccountsAccount,
|
||||
} from '@memohai/sdk'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './store'
|
||||
@@ -1,110 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type CliConfig = {
|
||||
host: string
|
||||
port: number
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export type TokenInfo = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_at: string
|
||||
user_id: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const defaultConfig: CliConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
session_id: '',
|
||||
}
|
||||
|
||||
const memohDir = () => join(homedir(), '.memoh')
|
||||
const configPath = () => join(memohDir(), 'config.toml')
|
||||
const tokenPath = () => join(memohDir(), 'token.json')
|
||||
|
||||
export const ensureStore = () => {
|
||||
const dir = memohDir()
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
const parseTomlConfig = (raw: string): CliConfig => {
|
||||
const result: CliConfig = { ...defaultConfig }
|
||||
const lines = raw.split(/\r?\n/)
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const match = trimmed.match(/^(\w+)\s*=\s*"?([^"]+)"?$/)
|
||||
if (!match) continue
|
||||
const key = match[1]
|
||||
const value = match[2]
|
||||
if (key === 'host') {
|
||||
result.host = value
|
||||
} else if (key === 'port') {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(parsed)) result.port = parsed
|
||||
} else if (key === 'session_id') {
|
||||
result.session_id = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const serializeTomlConfig = (config: CliConfig) => {
|
||||
return `host = "${config.host}"\nport = ${config.port}\nsession_id = "${config.session_id}"\n`
|
||||
}
|
||||
|
||||
export const readConfig = (): CliConfig => {
|
||||
ensureStore()
|
||||
const path = configPath()
|
||||
let config: CliConfig
|
||||
if (!existsSync(path)) {
|
||||
config = { ...defaultConfig }
|
||||
} else {
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
config = parseTomlConfig(raw)
|
||||
}
|
||||
// Auto-generate session_id on first run
|
||||
if (!config.session_id) {
|
||||
config.session_id = `cli:${randomUUID()}`
|
||||
writeFileSync(path, serializeTomlConfig(config), 'utf-8')
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export const writeConfig = (config: CliConfig) => {
|
||||
ensureStore()
|
||||
writeFileSync(configPath(), serializeTomlConfig(config), 'utf-8')
|
||||
}
|
||||
|
||||
export const readToken = (): TokenInfo | null => {
|
||||
ensureStore()
|
||||
if (!existsSync(tokenPath())) return null
|
||||
try {
|
||||
const raw = readFileSync(tokenPath(), 'utf-8')
|
||||
return JSON.parse(raw) as TokenInfo
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const writeToken = (token: TokenInfo) => {
|
||||
ensureStore()
|
||||
writeFileSync(tokenPath(), JSON.stringify(token, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export const clearToken = () => {
|
||||
ensureStore()
|
||||
writeFileSync(tokenPath(), '', 'utf-8')
|
||||
}
|
||||
|
||||
export const getBaseURL = (config: CliConfig) => {
|
||||
return `http://${config.host}:${config.port}`
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: { cli: 'src/cli/index.ts' },
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
clean: true,
|
||||
// @memohai/sdk exports raw .ts, must be bundled
|
||||
noExternal: [/^@memohai\/sdk/],
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user