Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
pitzcarraldo marked this conversation as resolved.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
188 changes: 162 additions & 26 deletions src/lib/auth/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { xdg } from '../utils/xdg';
import { findProjectRoot } from '../utils/path';
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';

/**
Expand All @@ -15,9 +22,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();
Expand All @@ -33,7 +44,7 @@ export class CredentialsManager {
private credentialsFilePath: string;

constructor(customStateDir?: string) {
this.stateDirPath = customStateDir ?? xdg.state();
this.stateDirPath = customStateDir ?? join(findProjectRoot(), '.clix');
this.credentialsFilePath = join(this.stateDirPath, 'credentials.json');
Comment thread
pitzcarraldo marked this conversation as resolved.
}

Expand Down Expand Up @@ -130,14 +141,53 @@ export class CredentialsManager {
}
}

// ============================================
// Clix (Auth0) Credentials Methods
// ============================================

/**
* Get Clix credentials from unified store.
*/
async getClixCredentials(): Promise<ClixCredentials | null> {
const credentials = await this.load();
return credentials?.clix ?? null;
}

/**
* Save Clix credentials to unified store.
*/
async saveClixCredentials(clixCredentials: ClixCredentials): Promise<void> {
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<void> {
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 access token is expired.
* 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;
Expand All @@ -146,15 +196,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<Credentials> {
if (!credentials.refreshToken) {
async refreshAccessToken(clixCredentials: ClixCredentials): Promise<ClixCredentials> {
if (!clixCredentials.refreshToken) {
throw AuthError.refreshFailed('No refresh token available');
}

Expand All @@ -170,7 +228,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),
});
Expand All @@ -188,20 +246,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;
Expand Down Expand Up @@ -233,21 +291,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
Expand Down Expand Up @@ -278,6 +336,84 @@ export class CredentialsManager {
return !!process.env[AUTH_ENV_VARS.ACCESS_TOKEN];
}

// ============================================
// Firebase Token Methods
// ============================================

/**
* Get Firebase tokens from unified store.
*/
async getFirebaseTokens(): Promise<FirebaseTokens | null> {
const credentials = await this.load();
return credentials?.firebase ?? null;
}

/**
* Save Firebase tokens to unified store.
*/
async saveFirebaseTokens(firebaseTokens: FirebaseTokens): Promise<void> {
const current = (await this.load()) ?? { version: CREDENTIALS_VERSION };
await this.save({
...current,
version: CREDENTIALS_VERSION,
firebase: firebaseTokens,
});
Comment thread
pitzcarraldo marked this conversation as resolved.
}

/**
* Clear only Firebase tokens (keep Clix credentials).
*/
async clearFirebaseTokens(): Promise<void> {
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 and have usable content.
*/
async hasFirebaseTokens(): Promise<boolean> {
const tokens = await this.getFirebaseTokens();
return !!(tokens?.access_token || tokens?.refresh_token);
}

/**
* 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.access_token) {
return true; // No access token, force refresh
}
if (!tokens.expiry_date) {
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;
}
Comment thread
pitzcarraldo marked this conversation as resolved.

/**
* 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).
*/
Expand Down
5 changes: 4 additions & 1 deletion src/lib/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading