From fe9e078f0ecbffc7afffbde38147c6b4b85a9b2e Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 5 Feb 2026 15:29:47 +0900 Subject: [PATCH 1/4] feat: migrate session and credentials storage to project-local .clix/ Consolidate all project-specific data into .clix/ directory: - Move sessions from XDG_STATE_HOME/clix/sessions/ to .clix/sessions/ - Move auth credentials from XDG_STATE_HOME/clix/ to .clix/credentials.json - Move Firebase tokens into unified credentials.json structure - Keep global config in XDG_CONFIG_HOME/clix/config.json Unified credentials structure: version: 1 clix?: { Auth0 tokens } firebase?: { Firebase OAuth tokens } This enables per-project credential management while maintaining global user preferences, improving multi-workspace workflows. Co-Authored-By: Claude (global.anthropic.claude-haiku-4-5-20251001-v1:0) --- AGENTS.md | 6 +- src/lib/auth/credentials.ts | 184 +++++++++++++++--- src/lib/auth/index.ts | 5 +- src/lib/auth/schema.ts | 54 +++-- .../services/firebase/oauth/token-store.ts | 61 ++---- src/lib/services/session-store.ts | 3 +- src/lib/services/transfer-service.ts | 3 +- src/ui/LoginUI.tsx | 14 +- src/ui/SetupUI.tsx | 7 +- 9 files changed, 238 insertions(+), 99 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5551a46..13dddb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,5 +259,7 @@ When adding new OAuth flows, use port 9005, path `/auth/callback`, the shared `O ## Security -Do not commit API keys or user data. Local config lives in `$XDG_CONFIG_HOME/clix/config.json` (default: `~/.config/clix/config.json`). -Sessions are stored in `$XDG_STATE_HOME/clix/sessions/` (default: `~/.local/state/clix/sessions/`). +Do not commit API keys or user data. Global config lives in `$XDG_CONFIG_HOME/clix/config.json` (default: `~/.config/clix/config.json`). +Project-local data is stored in `project/.clix/` directory: +- Sessions: `project/.clix/sessions/` +- Credentials: `project/.clix/credentials.json` (unified: Clix Auth + Firebase tokens) diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index 99098cb..7be0d63 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -1,9 +1,15 @@ import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { xdg } from '../utils/xdg'; import { AUTH_ENV_VARS, getAuth0Config } from './config'; import { AuthError } from './errors'; -import { type Credentials, createCredentials, validateCredentials } from './schema'; +import { + type ClixCredentials, + CREDENTIALS_VERSION, + type Credentials, + createClixCredentials, + type FirebaseTokens, + validateCredentials, +} from './schema'; import type { TokenResponse } from './types'; /** @@ -15,9 +21,13 @@ const EXPIRY_BUFFER_MS = 5 * 60 * 1000; /** * CredentialsManager handles storing, loading, and refreshing auth credentials. * - * Storage location: $XDG_STATE_HOME/clix/credentials.json + * Storage location: project/.clix/credentials.json * File permissions: 0600 (owner read/write only) * + * Unified credentials file structure: + * - clix: Auth0/Clix authentication tokens + * - firebase: Firebase OAuth tokens + * * @example * ```typescript * const manager = getCredentialsManager(); @@ -33,7 +43,7 @@ export class CredentialsManager { private credentialsFilePath: string; constructor(customStateDir?: string) { - this.stateDirPath = customStateDir ?? xdg.state(); + this.stateDirPath = customStateDir ?? join(process.cwd(), '.clix'); this.credentialsFilePath = join(this.stateDirPath, 'credentials.json'); } @@ -130,14 +140,53 @@ export class CredentialsManager { } } + // ============================================ + // Clix (Auth0) Credentials Methods + // ============================================ + + /** + * Get Clix credentials from unified store. + */ + async getClixCredentials(): Promise { + const credentials = await this.load(); + return credentials?.clix ?? null; + } + /** - * Check if access token is expired. + * Save Clix credentials to unified store. + */ + async saveClixCredentials(clixCredentials: ClixCredentials): Promise { + const current = (await this.load()) ?? { version: CREDENTIALS_VERSION }; + await this.save({ + ...current, + version: CREDENTIALS_VERSION, + clix: clixCredentials, + }); + } + + /** + * Clear only Clix credentials (keep Firebase tokens). + */ + async clearClixCredentials(): Promise { + const current = await this.load(); + if (current) { + const { clix: _, ...rest } = current; + if (rest.firebase) { + await this.save({ ...rest, version: CREDENTIALS_VERSION }); + } else { + await this.delete(); + } + } + } + + /** + * Check if Clix access token is expired. * - * @param credentials - Credentials to check + * @param clixCredentials - Clix credentials to check * @returns true if expired or about to expire */ - isExpired(credentials: Credentials): boolean { - const expiresAtMs = Date.parse(credentials.expiresAt); + isClixExpired(clixCredentials: ClixCredentials): boolean { + const expiresAtMs = Date.parse(clixCredentials.expiresAt); // Treat invalid dates as expired (secure default) if (!Number.isFinite(expiresAtMs)) { return true; @@ -146,15 +195,23 @@ export class CredentialsManager { return expiresAtMs - EXPIRY_BUFFER_MS <= Date.now(); } + /** + * @deprecated Use isClixExpired instead. + */ + isExpired(credentials: Credentials): boolean { + if (!credentials.clix) return true; + return this.isClixExpired(credentials.clix); + } + /** * Refresh access token using refresh token. * - * @param credentials - Current credentials with refresh token - * @returns New credentials with fresh access token + * @param clixCredentials - Current Clix credentials with refresh token + * @returns New Clix credentials with fresh access token * @throws AuthError if refresh fails */ - async refreshAccessToken(credentials: Credentials): Promise { - if (!credentials.refreshToken) { + async refreshAccessToken(clixCredentials: ClixCredentials): Promise { + if (!clixCredentials.refreshToken) { throw AuthError.refreshFailed('No refresh token available'); } @@ -170,7 +227,7 @@ export class CredentialsManager { body: new URLSearchParams({ grant_type: 'refresh_token', client_id: config.clientId, - refresh_token: credentials.refreshToken, + refresh_token: clixCredentials.refreshToken, }), signal: AbortSignal.timeout(30_000), }); @@ -188,20 +245,20 @@ export class CredentialsManager { // Preserve existing refresh token if response omits it (RFC 6749 compliant) const mergedTokenResponse: TokenResponse = { ...tokenResponse, - refresh_token: tokenResponse.refresh_token ?? credentials.refreshToken, + refresh_token: tokenResponse.refresh_token ?? clixCredentials.refreshToken, }; // Create new credentials with refreshed tokens - const newCredentials = createCredentials( + const newClixCredentials = createClixCredentials( mergedTokenResponse, - credentials.issuer, - credentials.audience, + clixCredentials.issuer, + clixCredentials.audience, ); // Save updated credentials - await this.save(newCredentials); + await this.saveClixCredentials(newClixCredentials); - return newCredentials; + return newClixCredentials; } catch (error) { if (error instanceof AuthError) { throw error; @@ -233,21 +290,21 @@ export class CredentialsManager { return envToken; } - // 2. Load stored credentials - const credentials = await this.load(); - if (!credentials) { + // 2. Load stored Clix credentials + const clixCredentials = await this.getClixCredentials(); + if (!clixCredentials) { return null; } // 3. Check if access token is expired - if (!this.isExpired(credentials)) { - return credentials.accessToken; + if (!this.isClixExpired(clixCredentials)) { + return clixCredentials.accessToken; } // 4. Try to refresh if we have a refresh token - if (credentials.refreshToken) { + if (clixCredentials.refreshToken) { try { - const refreshed = await this.refreshAccessToken(credentials); + const refreshed = await this.refreshAccessToken(clixCredentials); return refreshed.accessToken; } catch { // Refresh failed - return null to indicate re-login needed @@ -278,6 +335,81 @@ export class CredentialsManager { return !!process.env[AUTH_ENV_VARS.ACCESS_TOKEN]; } + // ============================================ + // Firebase Token Methods + // ============================================ + + /** + * Get Firebase tokens from unified store. + */ + async getFirebaseTokens(): Promise { + const credentials = await this.load(); + return credentials?.firebase ?? null; + } + + /** + * Save Firebase tokens to unified store. + */ + async saveFirebaseTokens(firebaseTokens: FirebaseTokens): Promise { + const current = (await this.load()) ?? { version: CREDENTIALS_VERSION }; + await this.save({ + ...current, + version: CREDENTIALS_VERSION, + firebase: firebaseTokens, + }); + } + + /** + * Clear only Firebase tokens (keep Clix credentials). + */ + async clearFirebaseTokens(): Promise { + const current = await this.load(); + if (current) { + const { firebase: _, ...rest } = current; + if (rest.clix) { + await this.save({ ...rest, version: CREDENTIALS_VERSION }); + } else { + await this.delete(); + } + } + } + + /** + * Check if Firebase tokens exist. + */ + async hasFirebaseTokens(): Promise { + const tokens = await this.getFirebaseTokens(); + return tokens !== null; + } + + /** + * Check if Firebase tokens are expired. + * + * @param tokens - Firebase tokens to check + * @returns true if tokens are expired or will expire within 5 minutes + */ + isFirebaseExpired(tokens: FirebaseTokens): boolean { + if (!tokens.expiry_date) { + return false; // No expiry info, assume valid + } + // Consider expired if less than 5 minutes remaining + return Date.now() >= tokens.expiry_date - EXPIRY_BUFFER_MS; + } + + /** + * Check if Firebase tokens have a valid refresh token. + * + * @param tokens - Firebase tokens to check + * @returns true if refresh token exists + */ + hasFirebaseRefreshToken(tokens: FirebaseTokens): boolean { + return !!tokens.refresh_token; + } + + // ============================================ + // Utility Methods + // ============================================ + /** * Clear the cached credentials (useful for testing). */ diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index bf05522..7ad47f3 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -14,12 +14,15 @@ export type { AuthErrorCode } from './errors'; // Errors export { AUTH_ERROR_CODES, AuthError } from './errors'; export { PKCEFlowService } from './pkce-flow'; -export type { Credentials } from './schema'; +export type { ClixCredentials, Credentials, FirebaseTokens } from './schema'; // Schema export { + ClixCredentialsSchema, CREDENTIALS_VERSION, CredentialsSchema, + createClixCredentials, createCredentials, + FirebaseTokensSchema, validateCredentials, } from './schema'; export type { Auth0Config, RefreshTokenRequest, TokenResponse, UserInfo } from './types'; diff --git a/src/lib/auth/schema.ts b/src/lib/auth/schema.ts index 398d003..f3704c1 100644 --- a/src/lib/auth/schema.ts +++ b/src/lib/auth/schema.ts @@ -2,16 +2,13 @@ import { z } from 'zod'; /** * Current credentials schema version. - * Increment when making breaking changes to structure. */ export const CREDENTIALS_VERSION = 1; /** - * Zod schema for stored credentials. + * Zod schema for Clix (Auth0) credentials. */ -export const CredentialsSchema = z.object({ - /** Schema version for migrations */ - version: z.number().int().min(1), +export const ClixCredentialsSchema = z.object({ /** Auth0 access token */ accessToken: z.string().min(1), /** Auth0 refresh token (for session persistence) */ @@ -29,8 +26,38 @@ export const CredentialsSchema = z.object({ }); /** - * Inferred type from CredentialsSchema. + * Zod schema for Firebase OAuth tokens. + */ +export const FirebaseTokensSchema = z.object({ + /** Firebase access token */ + access_token: z.string().nullish(), + /** Firebase refresh token */ + refresh_token: z.string().nullish(), + /** OAuth scope */ + scope: z.string().optional(), + /** Token type (e.g., "Bearer") */ + token_type: z.string().nullish(), + /** Token expiration timestamp (ms) */ + expiry_date: z.number().nullish(), +}); + +/** + * Zod schema for unified credentials file. */ +export const CredentialsSchema = z.object({ + /** Schema version */ + version: z.number().int().min(1), + /** Clix (Auth0) credentials */ + clix: ClixCredentialsSchema.optional(), + /** Firebase OAuth tokens */ + firebase: FirebaseTokensSchema.optional(), +}); + +/** + * Inferred types from schemas. + */ +export type ClixCredentials = z.infer; +export type FirebaseTokens = z.infer; export type Credentials = z.infer; /** @@ -45,14 +72,14 @@ export function validateCredentials(data: unknown): Credentials | null { } /** - * Create credentials object from token response. + * Create Clix credentials object from token response. * * @param tokenResponse - Auth0 token response * @param issuer - Auth0 issuer URL * @param audience - API audience - * @returns Credentials object ready for storage + * @returns ClixCredentials object ready for storage */ -export function createCredentials( +export function createClixCredentials( tokenResponse: { access_token: string; refresh_token?: string; @@ -61,7 +88,7 @@ export function createCredentials( }, issuer: string, audience: string, -): Credentials { +): ClixCredentials { const now = new Date(); const expiresInMs = tokenResponse.expires_in * 1000; @@ -73,7 +100,6 @@ export function createCredentials( const expiresAt = new Date(now.getTime() + expiresInMs); return { - version: CREDENTIALS_VERSION, accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, idToken: tokenResponse.id_token, @@ -83,3 +109,9 @@ export function createCredentials( audience, }; } + +/** + * @deprecated Use createClixCredentials instead. + * Kept for backward compatibility during transition. + */ +export const createCredentials = createClixCredentials; diff --git a/src/lib/services/firebase/oauth/token-store.ts b/src/lib/services/firebase/oauth/token-store.ts index 27de74f..a56a4f2 100644 --- a/src/lib/services/firebase/oauth/token-store.ts +++ b/src/lib/services/firebase/oauth/token-store.ts @@ -1,40 +1,29 @@ /** * Token storage for OAuth credentials. * - * Stores OAuth tokens in the XDG config directory (~/.config/clix/). + * Delegates to CredentialsManager for unified credential storage. * * @module services/firebase/oauth/token-store */ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { xdg } from '@/lib/utils/xdg'; +import { getCredentialsManager } from '@/lib/auth/credentials'; import type { OAuthTokens } from './types'; -const TOKEN_FILE_NAME = 'firebase-tokens.json'; - /** * Token store for persisting OAuth tokens. + * + * Uses CredentialsManager to store Firebase tokens in the unified + * credentials.json file at project/.clix/credentials.json */ export class TokenStore { - private tokenPath: string; - - constructor() { - this.tokenPath = path.join(xdg.config(), TOKEN_FILE_NAME); - } - /** * Load tokens from storage. * * @returns Stored tokens or null if not found */ async load(): Promise { - try { - const data = await fs.readFile(this.tokenPath, 'utf-8'); - return JSON.parse(data) as OAuthTokens; - } catch { - return null; - } + const manager = getCredentialsManager(); + return manager.getFirebaseTokens(); } /** @@ -43,25 +32,16 @@ export class TokenStore { * @param tokens - OAuth tokens to save */ async save(tokens: OAuthTokens): Promise { - const dir = path.dirname(this.tokenPath); - // Create directory with restricted permissions (owner only) - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - // Write token file with restricted permissions (owner read/write only) - await fs.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { - encoding: 'utf-8', - mode: 0o600, - }); + const manager = getCredentialsManager(); + await manager.saveFirebaseTokens(tokens); } /** * Clear stored tokens. */ async clear(): Promise { - try { - await fs.unlink(this.tokenPath); - } catch { - // Ignore if file doesn't exist - } + const manager = getCredentialsManager(); + await manager.clearFirebaseTokens(); } /** @@ -70,12 +50,8 @@ export class TokenStore { * @returns True if tokens file exists */ async exists(): Promise { - try { - await fs.access(this.tokenPath); - return true; - } catch { - return false; - } + const manager = getCredentialsManager(); + return manager.hasFirebaseTokens(); } /** @@ -85,12 +61,8 @@ export class TokenStore { * @returns True if tokens are expired or will expire within 5 minutes */ isExpired(tokens: OAuthTokens): boolean { - if (!tokens.expiry_date) { - return false; // No expiry info, assume valid - } - // Consider expired if less than 5 minutes remaining - const bufferMs = 5 * 60 * 1000; - return Date.now() >= tokens.expiry_date - bufferMs; + const manager = getCredentialsManager(); + return manager.isFirebaseExpired(tokens); } /** @@ -100,6 +72,7 @@ export class TokenStore { * @returns True if refresh token exists */ hasRefreshToken(tokens: OAuthTokens): boolean { - return !!tokens.refresh_token; + const manager = getCredentialsManager(); + return manager.hasFirebaseRefreshToken(tokens); } } diff --git a/src/lib/services/session-store.ts b/src/lib/services/session-store.ts index e94699d..f58771f 100644 --- a/src/lib/services/session-store.ts +++ b/src/lib/services/session-store.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { xdg } from '@/lib/utils/xdg'; import type { ChatMessage } from '@/ui/chat/context/ChatContext'; export const CHAT_SESSION_SCHEMA_VERSION = 1; @@ -45,7 +44,7 @@ function resolveSessionsDir(): string { if (process.env.CLIX_SESSION_DIR) { return process.env.CLIX_SESSION_DIR; } - return path.join(xdg.state(), 'sessions'); + return path.join(process.cwd(), '.clix', 'sessions'); } async function ensureSessionsDir(): Promise { diff --git a/src/lib/services/transfer-service.ts b/src/lib/services/transfer-service.ts index 5a10c8a..735c856 100644 --- a/src/lib/services/transfer-service.ts +++ b/src/lib/services/transfer-service.ts @@ -3,7 +3,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { ConversationMessage } from '../executor'; import { formatPath } from '../utils/path'; -import { xdg } from '../utils/xdg'; export type TransferAgent = 'claude' | 'codex'; @@ -40,7 +39,7 @@ function formatHistoryAsMarkdown(history: ConversationMessage[]): string { * Returns the path to the saved file. */ async function saveSessionHistory(history: ConversationMessage[]): Promise { - const clixDir = xdg.state(); + const clixDir = join(process.cwd(), '.clix'); // Ensure .clix directory exists try { diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index 97ccbb0..f30b1d8 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -4,8 +4,8 @@ import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { getInternalApiClient, type Member, type Organization, type Project } from '@/lib/api'; import { - type Credentials, - createCredentials, + type ClixCredentials, + createClixCredentials, getAuth0Config, getCredentialsManager, getIssuerUrl, @@ -40,7 +40,7 @@ interface OrgWithProjects { interface LoginUIProps { /** Called when login completes successfully */ - onComplete?: (credentials: Credentials) => void; + onComplete?: (credentials: ClixCredentials) => void; /** Called on error */ onError?: (error: Error) => void; } @@ -113,7 +113,7 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const [savedConfig, setSavedConfig] = useState(null); const [workspacePath] = useState(() => process.cwd()); const pkceServiceRef = useRef(null); - const credentialsRef = useRef(null); + const credentialsRef = useRef(null); const memberRef = useRef(null); const handleProjectSelect = useCallback( @@ -209,7 +209,7 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { setPhase('complete'); setTimeout(() => { - const creds = credentialsManager.credentials; + const creds = credentialsManager.credentials?.clix; if (onComplete && creds) { onComplete(creds); } else { @@ -238,8 +238,8 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { // Save credentials const issuer = getIssuerUrl(config); - const credentials = createCredentials(tokenResponse, issuer, config.audience); - await credentialsManager.save(credentials); + const credentials = createClixCredentials(tokenResponse, issuer, config.audience); + await credentialsManager.saveClixCredentials(credentials); // Verify login setPhase('verifying'); diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx index 5b93e1a..005b773 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -4,8 +4,7 @@ import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { getInternalApiClient, type Member, type Organization, type Project } from '@/lib/api'; import { - type Credentials, - createCredentials, + createClixCredentials, getAuth0Config, getCredentialsManager, getIssuerUrl, @@ -229,8 +228,8 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa // Save credentials const issuer = getIssuerUrl(config); - const credentials: Credentials = createCredentials(tokenResponse, issuer, config.audience); - await credentialsManager.save(credentials); + const credentials = createClixCredentials(tokenResponse, issuer, config.audience); + await credentialsManager.saveClixCredentials(credentials); // Fetch user data setPhase('fetching_data'); From c7ede5ade89e12fcaf879fcb992ce1feede65329 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 5 Feb 2026 15:35:22 +0900 Subject: [PATCH 2/4] fix: preserve Firebase tokens during Clix logout Use clearClixCredentials() instead of delete() in LogoutUI to only clear Clix auth tokens while preserving Firebase OAuth tokens. Co-Authored-By: Claude (global.anthropic.claude-haiku-4-5-20251001-v1:0) --- src/ui/LogoutUI.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/LogoutUI.tsx b/src/ui/LogoutUI.tsx index f417e93..de02a35 100644 --- a/src/ui/LogoutUI.tsx +++ b/src/ui/LogoutUI.tsx @@ -26,7 +26,7 @@ export const LogoutUI: React.FC = ({ onComplete }) => { setPhase('checking'); const credentials = await credentialsManager.load(); - if (!credentials) { + if (!credentials?.clix) { setPhase('not_logged_in'); setTimeout(() => { if (onComplete) { @@ -38,9 +38,9 @@ export const LogoutUI: React.FC = ({ onComplete }) => { return; } - // Delete credentials + // Clear Clix credentials only (preserve Firebase tokens) setPhase('deleting'); - await credentialsManager.delete(); + await credentialsManager.clearClixCredentials(); setPhase('complete'); setTimeout(() => { From cc57eb4f4e81f59c0246c819d69e64ce8ba58c9e Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 5 Feb 2026 15:40:57 +0900 Subject: [PATCH 3/4] docs: update storage path documentation and improve Firebase token validation - Update README.md and llms.txt to reflect project-local storage paths - Fix config path reference from ~/.clix/config.json to ~/.config/clix/config.json - Update session path references from ~/.local/state/clix to .clix/sessions - Improve hasFirebaseTokens() to check for actual token content - Improve isFirebaseExpired() to treat missing access_token or expiry_date as expired Addresses PR review feedback about documentation inconsistency and empty/partial Firebase token handling. Co-Authored-By: Claude Opus 4.5 --- README.md | 4 ++-- llms.txt | 20 ++++++++++---------- src/lib/auth/credentials.ts | 9 ++++++--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 138a028..ac12624 100644 --- a/README.md +++ b/README.md @@ -341,10 +341,10 @@ Transfer your conversation to continue in the native agent CLI: ``` > /transfer claude -✅ Session saved to ~/.local/state/clix/session-1234567890.md +✅ Session saved to .clix/sessions/session-1234567890.md To continue in Claude Code: -claude "$(cat ~/.local/state/clix/session-1234567890.md)" +claude "$(cat .clix/sessions/session-1234567890.md)" ``` This preserves your entire conversation history and allows you to continue seamlessly in the agent's native interface. diff --git a/llms.txt b/llms.txt index 891a04b..7ab8cf0 100644 --- a/llms.txt +++ b/llms.txt @@ -61,7 +61,7 @@ clix ``` **What happens:** -1. Loads configuration from `~/.clix/config.json` +1. Loads configuration from `~/.config/clix/config.json` 2. Detects available AI agents (Claude Code, Codex) 3. Prompts for agent selection if not configured or multiple available 4. Initializes executor with selected agent @@ -701,7 +701,7 @@ Switching to Claude... - `/transfer gemini` - Transfers to Gemini **Process:** -1. Conversation history saved to `~/.local/state/clix/sessions/session-{timestamp}.md` +1. Conversation history saved to `.clix/sessions/session-{timestamp}.md` 2. Markdown formatted with full history 3. Command generated to continue in agent CLI 4. User exits and runs provided command @@ -711,11 +711,11 @@ Switching to Claude... > /transfer claude ✅ Session saved! -File: ~/.local/state/clix/sessions/session-1704735000000.md +File: .clix/sessions/session-1704735000000.md To continue in Claude Code: -claude "$(cat ~/.local/state/clix/sessions/session-1704735000000.md)" +claude "$(cat .clix/sessions/session-1704735000000.md)" ``` #### `/resume` - Resume Session @@ -1003,13 +1003,13 @@ Switching to Claude... clix [Having a long conversation] > /transfer claude -✅ Session saved to ~/.local/state/clix/sessions/session-1704735000.md +✅ Session saved to .clix/sessions/session-1704735000.md To continue in Claude Code: -claude "$(cat ~/.local/state/clix/sessions/session-1704735000.md)" +claude "$(cat .clix/sessions/session-1704735000.md)" # Exit and run provided command -$ claude "$(cat ~/.local/state/clix/sessions/session-1704735000.md)" +$ claude "$(cat .clix/sessions/session-1704735000.md)" [Conversation continues in native Claude CLI] ``` @@ -1118,7 +1118,7 @@ interface AgentExecutor { ### Session Files -**Location:** `~/.local/state/clix/sessions/` (or `$XDG_STATE_HOME/clix/sessions/`) +**Location:** `.clix/sessions/` (project-local) **Format:** Markdown with conversation history @@ -1279,11 +1279,11 @@ When helping users with Clix CLI, keep these points in mind: 5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`, `/ios-setup`) can run from CLI, Interactive skills (`/integration`, `/event-tracking`, etc.) require chat mode 6. **Skills from package** - Interactive skills from @clix-so/clix-agent-skills package, Autonomous commands are local 7. **/install vs /integration** - `/install` makes changes autonomously, `/integration` provides guided steps -8. **Session transfer** - Saves to `~/.local/state/clix/sessions/`, provides command for native CLI +8. **Session transfer** - Saves to `.clix/sessions/`, provides command for native CLI 9. **Agent switching** - Preserves history when switching between any agents 10. **Context management** - 200K tokens (Claude), auto-compact at 90% 11. **MCP integration** - Built-in installer for Clix MCP Server supporting all agents -12. **XDG paths** - Config in `~/.config/clix/`, sessions in `~/.local/state/clix/sessions/` +12. **Storage paths** - Global config in `~/.config/clix/`, project-local data in `.clix/` (sessions, credentials) **When users ask about:** - "How to use" → Emphasize `clix` command starts chat, use `/help` for commands diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index 7be0d63..9621e30 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -375,11 +375,11 @@ export class CredentialsManager { } /** - * Check if Firebase tokens exist. + * Check if Firebase tokens exist and have usable content. */ async hasFirebaseTokens(): Promise { const tokens = await this.getFirebaseTokens(); - return tokens !== null; + return !!(tokens?.access_token || tokens?.refresh_token); } /** @@ -389,8 +389,11 @@ export class CredentialsManager { * @returns true if tokens are expired or will expire within 5 minutes */ isFirebaseExpired(tokens: FirebaseTokens): boolean { + if (!tokens.access_token) { + return true; // No access token, force refresh + } if (!tokens.expiry_date) { - return false; // No expiry info, assume valid + return true; // Missing expiry info, treat as expired } // Consider expired if less than 5 minutes remaining return Date.now() >= tokens.expiry_date - EXPIRY_BUFFER_MS; From df2978ea58ec9ba6c7248396367756785736d3e4 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 5 Feb 2026 15:49:19 +0900 Subject: [PATCH 4/4] fix: use project root resolution for .clix directory path Add findProjectRoot() utility that walks up directories to find project root markers (package.json, .git, .clix, etc.) instead of using process.cwd() directly. This ensures credentials and sessions are stored relative to the actual project root, not the current working directory. Applied to: - CredentialsManager: credentials stored at project_root/.clix/ - session-store: sessions stored at project_root/.clix/sessions/ - transfer-service: session files saved to project_root/.clix/ This prevents inconsistent behavior when users run clix from subdirectories. Co-Authored-By: Claude Opus 4.5 --- src/lib/auth/credentials.ts | 3 +- src/lib/services/session-store.ts | 3 +- src/lib/services/transfer-service.ts | 4 +-- src/lib/utils/path.ts | 41 ++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index 9621e30..1dd842e 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -1,5 +1,6 @@ import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { findProjectRoot } from '../utils/path'; import { AUTH_ENV_VARS, getAuth0Config } from './config'; import { AuthError } from './errors'; import { @@ -43,7 +44,7 @@ export class CredentialsManager { private credentialsFilePath: string; constructor(customStateDir?: string) { - this.stateDirPath = customStateDir ?? join(process.cwd(), '.clix'); + this.stateDirPath = customStateDir ?? join(findProjectRoot(), '.clix'); this.credentialsFilePath = join(this.stateDirPath, 'credentials.json'); } diff --git a/src/lib/services/session-store.ts b/src/lib/services/session-store.ts index f58771f..44ee2bb 100644 --- a/src/lib/services/session-store.ts +++ b/src/lib/services/session-store.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { ChatMessage } from '@/ui/chat/context/ChatContext'; +import { findProjectRoot } from '../utils/path'; export const CHAT_SESSION_SCHEMA_VERSION = 1; @@ -44,7 +45,7 @@ function resolveSessionsDir(): string { if (process.env.CLIX_SESSION_DIR) { return process.env.CLIX_SESSION_DIR; } - return path.join(process.cwd(), '.clix', 'sessions'); + return path.join(findProjectRoot(), '.clix', 'sessions'); } async function ensureSessionsDir(): Promise { diff --git a/src/lib/services/transfer-service.ts b/src/lib/services/transfer-service.ts index 735c856..777cca8 100644 --- a/src/lib/services/transfer-service.ts +++ b/src/lib/services/transfer-service.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { ConversationMessage } from '../executor'; -import { formatPath } from '../utils/path'; +import { findProjectRoot, formatPath } from '../utils/path'; export type TransferAgent = 'claude' | 'codex'; @@ -39,7 +39,7 @@ function formatHistoryAsMarkdown(history: ConversationMessage[]): string { * Returns the path to the saved file. */ async function saveSessionHistory(history: ConversationMessage[]): Promise { - const clixDir = join(process.cwd(), '.clix'); + const clixDir = join(findProjectRoot(), '.clix'); // Ensure .clix directory exists try { diff --git a/src/lib/utils/path.ts b/src/lib/utils/path.ts index 0c2c10a..dce2225 100644 --- a/src/lib/utils/path.ts +++ b/src/lib/utils/path.ts @@ -1,4 +1,45 @@ +import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; +import { dirname, join, parse } from 'node:path'; + +/** + * Project root markers in order of priority. + */ +const PROJECT_MARKERS = [ + '.clix', // Clix config directory (highest priority - already initialized) + 'package.json', // Node.js project + '.git', // Git repository + 'Podfile', // iOS project + 'Package.swift', // Swift package + 'pubspec.yaml', // Flutter project + 'build.gradle', // Android/Gradle project +]; + +/** + * Find the project root by walking up directories looking for project markers. + * + * @param startDir - Starting directory (defaults to process.cwd()) + * @returns Project root path, or the starting directory if no markers found + */ +export function findProjectRoot(startDir?: string): string { + let currentDir = startDir ?? process.cwd(); + const { root } = parse(currentDir); + + while (currentDir !== root) { + // Check for any project markers + for (const marker of PROJECT_MARKERS) { + const markerPath = join(currentDir, marker); + if (existsSync(markerPath)) { + return currentDir; + } + } + // Move up one directory + currentDir = dirname(currentDir); + } + + // No markers found, return the original starting directory + return startDir ?? process.cwd(); +} /** * Formats a file path by replacing the home directory with ~