refactor: cli

This commit is contained in:
Acbox
2026-01-11 19:14:58 +08:00
parent cc3e85c8b0
commit f783457160
12 changed files with 438 additions and 141 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import ora from 'ora'
import { table } from 'table'
import * as modelCore from '../../core/model'
import { formatError } from '../../utils'
import { getApiUrl } from '../../core/config'
import { getApiUrl } from '../../core/client'
export function modelCommands(program: Command) {
program
Regular → Executable
View File
+57 -8
View File
@@ -1,4 +1,5 @@
import { requireAuth, getToken, getApiUrl } from './client'
import type { MemoHomeContext } from './context'
export interface ChatParams {
message: string
@@ -16,16 +17,48 @@ export interface StreamEvent {
export type StreamCallback = (event: StreamEvent) => void | Promise<void>
/**
* Chat with AI Agent (streaming)
* Chat with AI Agent (streaming) - sync version
*/
export async function chatStream(
params: ChatParams,
onEvent: StreamCallback,
context?: MemoHomeContext
): Promise<void> {
requireAuth(context)
const token = getToken(context)!
const apiUrl = getApiUrl(context)
await performStreamChat(apiUrl, token, params, onEvent)
}
/**
* Chat with AI Agent (streaming) - async version for Redis storage
*/
export async function chatStreamAsync(
params: ChatParams,
onEvent: StreamCallback,
context?: MemoHomeContext
): Promise<void> {
requireAuth(context)
const token = getToken(context)!
const apiUrl = getApiUrl(context)
if (!token) {
throw new Error('Not authenticated')
}
await performStreamChat(apiUrl, token, params, onEvent)
}
/**
* Internal function to perform streaming chat
*/
async function performStreamChat(
apiUrl: string,
token: string,
params: ChatParams,
onEvent: StreamCallback
): Promise<void> {
requireAuth()
const token = getToken()!
const apiUrl = getApiUrl()
const response = await fetch(`${apiUrl}/agent/stream`, {
method: 'POST',
headers: {
@@ -91,9 +124,9 @@ export async function chatStream(
}
/**
* Chat with AI Agent (non-streaming, collect full response)
* Chat with AI Agent (non-streaming, collect full response) - sync version
*/
export async function chat(params: ChatParams): Promise<string> {
export async function chat(params: ChatParams, context?: MemoHomeContext): Promise<string> {
let fullResponse = ''
await chatStream(params, async (event) => {
@@ -102,8 +135,24 @@ export async function chat(params: ChatParams): Promise<string> {
} else if (event.type === 'error') {
throw new Error(event.error)
}
})
}, context)
return fullResponse
}
/**
* Chat with AI Agent (non-streaming, collect full response) - async version
*/
export async function chatAsync(params: ChatParams, context?: MemoHomeContext): Promise<string> {
let fullResponse = ''
await chatStreamAsync(params, async (event) => {
if (event.type === 'text-delta' && event.text) {
fullResponse += event.text
} else if (event.type === 'error') {
throw new Error(event.error)
}
}, context)
return fullResponse
}
+77 -21
View File
@@ -1,5 +1,5 @@
import { createClient } from './client'
import { setToken, clearToken, getToken, getApiUrl, setApiUrl } from './config'
import { getContext, type MemoHomeContext } from './context'
export interface LoginParams {
username: string
@@ -28,10 +28,12 @@ export interface ConfigInfo {
}
/**
* Login to MemoHome API
* Login to MemoHome API (sync version for file storage)
* @param params - Login parameters
* @param context - Optional context
*/
export async function login(params: LoginParams): Promise<LoginResult> {
const client = createClient()
export async function login(params: LoginParams, context?: MemoHomeContext): Promise<LoginResult> {
const client = createClient(context)
const response = await client.auth.login.post({
username: params.username,
@@ -42,10 +44,18 @@ export async function login(params: LoginParams): Promise<LoginResult> {
throw new Error(response.error.value)
}
const data = response.data as { success?: boolean; data?: { token?: string; user?: { username: string; role: string } } } | null
const data = response.data as { success?: boolean; data?: { token?: string; user?: { username: string; role: string; id: string } } } | null
if (data?.success && data?.data?.token && data?.data?.user) {
setToken(data.data.token)
const ctx = context || getContext()
const storage = ctx.storage
// Set token (handle both sync and async)
const setResult = storage.setToken(data.data.token, ctx.currentUserId)
if (setResult instanceof Promise) {
await setResult
}
return {
success: true,
token: data.data.token,
@@ -58,28 +68,55 @@ export async function login(params: LoginParams): Promise<LoginResult> {
/**
* Logout current user
* @param context - Optional context
*/
export function logout(): void {
clearToken()
export function logout(context?: MemoHomeContext): void {
const ctx = context || getContext()
const storage = ctx.storage
const result = storage.clearToken(ctx.currentUserId)
if (result instanceof Promise) {
throw new Error('logout does not support async storage. Use logoutAsync instead.')
}
}
/**
* Check if user is logged in
* @param context - Optional context
*/
export function isLoggedIn(): boolean {
return getToken() !== null
export function isLoggedIn(context?: MemoHomeContext): boolean {
const ctx = context || getContext()
const storage = ctx.storage
const token = storage.getToken(ctx.currentUserId)
if (token instanceof Promise) {
throw new Error('isLoggedIn does not support async storage. Use isLoggedInAsync instead.')
}
return token !== null
}
/**
* Get current logged in user info
* @param context - Optional context
*/
export async function getCurrentUser(): Promise<UserInfo> {
const token = getToken()
export async function getCurrentUser(context?: MemoHomeContext): Promise<UserInfo> {
const ctx = context || getContext()
const storage = ctx.storage
const token = storage.getToken(ctx.currentUserId)
if (token instanceof Promise) {
throw new Error('getCurrentUser does not support async storage. Use getCurrentUserAsync instead.')
}
if (!token) {
throw new Error('Not logged in')
}
const client = createClient()
const client = createClient(context)
const response = await client.auth.me.get()
if (response.error) {
@@ -97,21 +134,40 @@ export async function getCurrentUser(): Promise<UserInfo> {
/**
* Get current API configuration
* @param context - Optional context
*/
export function getConfig(): ConfigInfo {
export function getConfig(context?: MemoHomeContext): ConfigInfo {
const ctx = context || getContext()
const storage = ctx.storage
const apiUrl = storage.getApiUrl()
const token = storage.getToken(ctx.currentUserId)
if (apiUrl instanceof Promise || token instanceof Promise) {
throw new Error('getConfig does not support async storage. Use getConfigAsync instead.')
}
return {
apiUrl: getApiUrl(),
loggedIn: isLoggedIn(),
apiUrl: apiUrl as string,
loggedIn: token !== null,
}
}
/**
* Set API URL
* @param url - API URL
* @param context - Optional context
*/
export function setConfig(apiUrl: string): void {
setApiUrl(apiUrl)
export function setConfig(url: string, context?: MemoHomeContext): void {
const ctx = context || getContext()
const storage = ctx.storage
const result = storage.setApiUrl(url)
if (result instanceof Promise) {
throw new Error('setConfig does not support async storage. Use setConfigAsync instead.')
}
}
// Re-export config functions for convenience
export { getToken, getApiUrl, setToken, clearToken, setApiUrl }
// Re-export for backward compatibility
export { getToken, getApiUrl } from './client'
export { getContext, setContext, createContext } from './context'
+87 -19
View File
@@ -1,29 +1,97 @@
import { treaty } from '@elysiajs/eden'
import { getApiUrl, getToken } from './config'
import { getContext, type MemoHomeContext } from './context'
import { createClient as createClientApi } from '@memohome/api/client'
// Use dynamic import to avoid type errors
export function createClient() {
const apiUrl = getApiUrl()
const token = getToken()
/**
* Create API client
* @param context - Optional context, uses global context if not provided
*/
export function createClient(context?: MemoHomeContext) {
const ctx = context || getContext()
const storage = ctx.storage
const apiUrlResult = typeof storage.getApiUrl === 'function'
? storage.getApiUrl()
: (storage as unknown as Record<string, string>).apiUrl
if (apiUrlResult instanceof Promise) {
throw new Error('createClient does not support async storage. Use createClientAsync instead.')
}
const apiUrl = apiUrlResult as string
const token = typeof storage.getToken === 'function'
? storage.getToken(ctx.currentUserId)
: null
// Eden Treaty configuration
const client = treaty(apiUrl, {
headers: token ? {
'Authorization': `Bearer ${token}`,
} : undefined,
})
// Handle async token retrieval
if (token instanceof Promise) {
throw new Error('createClient does not support async token storage. Use createClientAsync instead.')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return client as any
const client = createClientApi(apiUrl, token ?? undefined)
return client
}
export function requireAuth(): string {
const token = getToken()
if (!token) {
throw new Error('Not logged in. Please use "memohome auth login" to login first')
/**
* Require authentication
* Throws error if not authenticated
* @param context - Optional context, uses global context if not provided
*/
export function requireAuth(context?: MemoHomeContext): string {
const ctx = context || getContext()
const storage = ctx.storage
const token = typeof storage.getToken === 'function'
? storage.getToken(ctx.currentUserId)
: null
if (token instanceof Promise) {
throw new Error('requireAuth does not support async token storage. Use requireAuthAsync instead.')
}
if (!token) {
throw new Error('Not logged in. Please login first')
}
return token
}
export { getApiUrl, getToken }
/**
* Get API URL
* @param context - Optional context, uses global context if not provided
*/
export function getApiUrl(context?: MemoHomeContext): string {
const ctx = context || getContext()
const storage = ctx.storage
const urlResult = typeof storage.getApiUrl === 'function'
? storage.getApiUrl()
: (storage as unknown as Record<string, string>).apiUrl
if (urlResult instanceof Promise) {
throw new Error('getApiUrl does not support async storage. Use getApiUrlAsync instead.')
}
return urlResult as string
}
/**
* Get token
* @param context - Optional context, uses global context if not provided
*/
export function getToken(context?: MemoHomeContext): string | null {
const ctx = context || getContext()
const storage = ctx.storage
const token = typeof storage.getToken === 'function'
? storage.getToken(ctx.currentUserId)
: null
if (token instanceof Promise) {
throw new Error('getToken does not support async storage. Use getTokenAsync instead.')
}
return token
}
-71
View File
@@ -1,71 +0,0 @@
import { homedir } from 'os'
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
const CONFIG_DIR = join(homedir(), '.memohome')
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
export interface Config {
apiUrl: string
token?: string
}
const DEFAULT_CONFIG: Config = {
apiUrl: process.env.API_BASE_URL || 'http://localhost:7002',
}
export function ensureConfigDir() {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true })
}
}
export function loadConfig(): Config {
ensureConfigDir()
if (!existsSync(CONFIG_FILE)) {
saveConfig(DEFAULT_CONFIG)
return DEFAULT_CONFIG
}
try {
const data = readFileSync(CONFIG_FILE, 'utf-8')
return { ...DEFAULT_CONFIG, ...JSON.parse(data) }
} catch {
return DEFAULT_CONFIG
}
}
export function saveConfig(config: Config) {
ensureConfigDir()
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
}
export function getToken(): string | null {
const config = loadConfig()
return config.token || null
}
export function setToken(token: string) {
const config = loadConfig()
config.token = token
saveConfig(config)
}
export function clearToken() {
const config = loadConfig()
delete config.token
saveConfig(config)
}
export function getApiUrl(): string {
const config = loadConfig()
return config.apiUrl
}
export function setApiUrl(url: string) {
const config = loadConfig()
config.apiUrl = url
saveConfig(config)
}
+62
View File
@@ -0,0 +1,62 @@
/**
* MemoHome Core Context
*
* Provides a configurable context for core functions to use different storage backends
*/
import type { TokenStorage } from './storage'
import { FileTokenStorage } from './storage/file'
/**
* Global context for core functions
*/
export interface MemoHomeContext {
storage: TokenStorage
currentUserId?: string
}
/**
* Default context (uses file storage for CLI)
*/
let defaultContext: MemoHomeContext = {
storage: new FileTokenStorage(),
}
/**
* Get the current context
*/
export function getContext(): MemoHomeContext {
return defaultContext
}
/**
* Set the global context
* Use this to configure storage backend (e.g., Redis for Telegram bot)
*/
export function setContext(context: Partial<MemoHomeContext>): void {
defaultContext = { ...defaultContext, ...context }
}
/**
* Create a new context without modifying the global one
* Useful for multi-user scenarios
*/
export function createContext(options: {
storage: TokenStorage
userId?: string
}): MemoHomeContext {
return {
storage: options.storage,
currentUserId: options.userId,
}
}
/**
* Reset context to default (file storage)
*/
export function resetContext(): void {
defaultContext = {
storage: new FileTokenStorage(),
}
}
+10 -7
View File
@@ -1,4 +1,5 @@
import { getApiUrl, getToken } from './config'
import { getApiUrl, getToken } from './client'
import type { MemoHomeContext } from './context'
export interface PingResult {
success: boolean
@@ -9,10 +10,11 @@ export interface PingResult {
/**
* Test API server connection
* @param context - Optional context, uses global context if not provided
*/
export async function ping(): Promise<PingResult> {
const apiUrl = getApiUrl()
const token = getToken()
export async function ping(context?: MemoHomeContext): Promise<PingResult> {
const apiUrl = getApiUrl(context)
const token = getToken(context)
try {
const controller = new AbortController()
@@ -63,14 +65,15 @@ export async function ping(): Promise<PingResult> {
/**
* Get connection info
* @param context - Optional context, uses global context if not provided
*/
export function getConnectionInfo(): {
export function getConnectionInfo(context?: MemoHomeContext): {
apiUrl: string
hasToken: boolean
} {
return {
apiUrl: getApiUrl(),
hasToken: getToken() !== null,
apiUrl: getApiUrl(context),
hasToken: getToken(context) !== null,
}
}
+17 -14
View File
@@ -5,6 +5,19 @@
* All functions are independent of CLI-specific UI concerns (no chalk, ora, inquirer, etc.)
*/
// Context
export {
getContext,
setContext,
createContext,
resetContext,
type MemoHomeContext,
} from './context'
// Storage
export type { TokenStorage, Config } from './storage'
export { FileTokenStorage } from './storage/'
// Auth
export {
login,
@@ -13,11 +26,6 @@ export {
getCurrentUser,
getConfig,
setConfig,
getToken,
getApiUrl,
setToken,
clearToken,
setApiUrl,
type LoginParams,
type LoginResult,
type UserInfo,
@@ -49,7 +57,9 @@ export {
// Agent
export {
chat,
chatAsync,
chatStream,
chatStreamAsync,
type ChatParams,
type StreamEvent,
type StreamCallback,
@@ -93,17 +103,10 @@ export {
type PingResult,
} from './debug'
// Config
export {
loadConfig,
saveConfig,
ensureConfigDir,
type Config,
} from './config'
// Client
export {
createClient,
requireAuth,
getApiUrl,
getToken,
} from './client'
+52
View File
@@ -0,0 +1,52 @@
/**
* Token Storage Interface
*
* Abstraction for storing authentication tokens in different backends
*/
export interface Config {
apiUrl: string
token?: string
}
export interface TokenStorage {
/**
* Get the API URL
*/
getApiUrl(): Promise<string> | string
/**
* Set the API URL
*/
setApiUrl(url: string): Promise<void> | void
/**
* Get the authentication token for a user
* @param userId - User identifier (optional for single-user storage)
*/
getToken(userId?: string): Promise<string | null> | string | null
/**
* Set the authentication token for a user
* @param token - The authentication token
* @param userId - User identifier (optional for single-user storage)
*/
setToken(token: string, userId?: string): Promise<void> | void
/**
* Clear the authentication token for a user
* @param userId - User identifier (optional for single-user storage)
*/
clearToken(userId?: string): Promise<void> | void
/**
* Load full configuration (if applicable)
*/
loadConfig?(): Promise<Config> | Config
/**
* Save full configuration (if applicable)
*/
saveConfig?(config: Config): Promise<void> | void
}
+73
View File
@@ -0,0 +1,73 @@
import { homedir } from 'os'
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
import type { TokenStorage, Config } from '../storage'
const CONFIG_DIR = join(homedir(), '.memohome')
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
const DEFAULT_CONFIG: Config = {
apiUrl: process.env.API_BASE_URL || 'http://localhost:7002',
}
/**
* File-based token storage for CLI
* Stores config in ~/.memohome/config.json
*/
export class FileTokenStorage implements TokenStorage {
private ensureConfigDir() {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true })
}
}
loadConfig(): Config {
this.ensureConfigDir()
if (!existsSync(CONFIG_FILE)) {
this.saveConfig(DEFAULT_CONFIG)
return DEFAULT_CONFIG
}
try {
const data = readFileSync(CONFIG_FILE, 'utf-8')
return { ...DEFAULT_CONFIG, ...JSON.parse(data) }
} catch {
return DEFAULT_CONFIG
}
}
saveConfig(config: Config): void {
this.ensureConfigDir()
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
}
getApiUrl(): string {
const config = this.loadConfig()
return config.apiUrl
}
setApiUrl(url: string): void {
const config = this.loadConfig()
config.apiUrl = url
this.saveConfig(config)
}
getToken(): string | null {
const config = this.loadConfig()
return config.token || null
}
setToken(token: string): void {
const config = this.loadConfig()
config.token = token
this.saveConfig(config)
}
clearToken(): void {
const config = this.loadConfig()
delete config.token
this.saveConfig(config)
}
}
+2
View File
@@ -0,0 +1,2 @@
export { FileTokenStorage } from './file'
export type { TokenStorage, Config } from '../storage'