diff --git a/packages/cli/src/cli/commands/model.ts b/packages/cli/src/cli/commands/model.ts index 6ca7dbc1..34ab0364 100644 --- a/packages/cli/src/cli/commands/model.ts +++ b/packages/cli/src/cli/commands/model.ts @@ -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 diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts old mode 100644 new mode 100755 diff --git a/packages/cli/src/core/agent.ts b/packages/cli/src/core/agent.ts index ee865c3e..fa8bfb6c 100644 --- a/packages/cli/src/core/agent.ts +++ b/packages/cli/src/core/agent.ts @@ -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 /** - * Chat with AI Agent (streaming) + * Chat with AI Agent (streaming) - sync version */ export async function chatStream( + params: ChatParams, + onEvent: StreamCallback, + context?: MemoHomeContext +): Promise { + 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 { + 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 { - 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 { +export async function chat(params: ChatParams, context?: MemoHomeContext): Promise { let fullResponse = '' await chatStream(params, async (event) => { @@ -102,8 +135,24 @@ export async function chat(params: ChatParams): Promise { } 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 { + 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 +} diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 236ca7e2..1391fdf9 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -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 { - const client = createClient() +export async function login(params: LoginParams, context?: MemoHomeContext): Promise { + 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 { 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 { /** * 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 { - const token = getToken() +export async function getCurrentUser(context?: MemoHomeContext): Promise { + 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 { /** * 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' diff --git a/packages/cli/src/core/client.ts b/packages/cli/src/core/client.ts index c9074d97..601cac9f 100644 --- a/packages/cli/src/core/client.ts +++ b/packages/cli/src/core/client.ts @@ -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).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).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 +} diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts deleted file mode 100644 index 11e0f870..00000000 --- a/packages/cli/src/core/config.ts +++ /dev/null @@ -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) -} - diff --git a/packages/cli/src/core/context.ts b/packages/cli/src/core/context.ts new file mode 100644 index 00000000..afa2efb3 --- /dev/null +++ b/packages/cli/src/core/context.ts @@ -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): 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(), + } +} + diff --git a/packages/cli/src/core/debug.ts b/packages/cli/src/core/debug.ts index cfab6526..c7eb369c 100644 --- a/packages/cli/src/core/debug.ts +++ b/packages/cli/src/core/debug.ts @@ -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 { - const apiUrl = getApiUrl() - const token = getToken() +export async function ping(context?: MemoHomeContext): Promise { + const apiUrl = getApiUrl(context) + const token = getToken(context) try { const controller = new AbortController() @@ -63,14 +65,15 @@ export async function ping(): Promise { /** * 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, } } diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index a9d9aa6d..d6d87748 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -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' - diff --git a/packages/cli/src/core/storage.ts b/packages/cli/src/core/storage.ts new file mode 100644 index 00000000..b1ed80bb --- /dev/null +++ b/packages/cli/src/core/storage.ts @@ -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 + + /** + * Set the API URL + */ + setApiUrl(url: string): Promise | void + + /** + * Get the authentication token for a user + * @param userId - User identifier (optional for single-user storage) + */ + getToken(userId?: string): Promise | 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 + + /** + * Clear the authentication token for a user + * @param userId - User identifier (optional for single-user storage) + */ + clearToken(userId?: string): Promise | void + + /** + * Load full configuration (if applicable) + */ + loadConfig?(): Promise | Config + + /** + * Save full configuration (if applicable) + */ + saveConfig?(config: Config): Promise | void +} + diff --git a/packages/cli/src/core/storage/file.ts b/packages/cli/src/core/storage/file.ts new file mode 100644 index 00000000..eb310601 --- /dev/null +++ b/packages/cli/src/core/storage/file.ts @@ -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) + } +} + diff --git a/packages/cli/src/core/storage/index.ts b/packages/cli/src/core/storage/index.ts new file mode 100644 index 00000000..b9dcc88d --- /dev/null +++ b/packages/cli/src/core/storage/index.ts @@ -0,0 +1,2 @@ +export { FileTokenStorage } from './file' +export type { TokenStorage, Config } from '../storage'