SPB Git

Repository Public

claude.ts 10.3 KB 248 lines typescript

1/**2 * Claude Code introspection (runtime-fs): /claude/info and /claude/conversations.3 * Read-only. Never starts a conversation, never returns account identifiers or tokens.4 *5 * Observed formats (claude 2.1.x on macOS):6 *  - `claude auth status --json` → {"loggedIn":bool,"authMethod":"api_key"|"none"|…,7 *    "apiProvider":…,"email":…,"orgId":…,"orgName":…,…}; exit code 0 in both states.8 *    Only `loggedIn`, `authMethod` and `subscriptionType` are ever read.9 *  - transcripts live in `<configDir>/projects/<cwd with every non-alphanumeric char → "-">/<uuid>.jsonl`.10 */11import { promises as fsp } from 'node:fs';12import path from 'node:path';13import { z } from 'zod';14import type { ClaudeConversation } from '@cr/shared';15import type { RuntimeConfig } from './config.js';16import { RtError, type RuntimePlugin } from './plugins.js';17import { assertRootAllowed } from './paths.js';18import { defaultClaudeEnv, resolveClaudeBinary, runClaude, type ClaudeEnv } from './fsx-claude-env.js';19import { listCodexSessions, parseCodexLoginStatus, parseCodexVersion } from './codex.js';2021export const MAX_TRANSCRIPT_READ = 4 * 1024 * 1024;22const MAX_CONVERSATIONS = 50;23const SUMMARY_CHARS = 160;24const INFO_TTL_MS = 30_000;2526export interface ClaudeOptions {27  env?: ClaudeEnv;28}2930export interface ClaudeInfo {31  path: string | null;32  version: string | null;33  authenticated: boolean | null;34  /** Non-sensitive label such as "api_key" or "claude.ai"; never an account identifier. */35  method: string | null;36  subscription: string | null;37  /** Absolute configuration directory of the agent (`~/.claude` / `$CLAUDE_CONFIG_DIR`, or `$CODEX_HOME`). */38  configDir: string;39  /** Set to `codex` by a Codex runtime; absent for Claude (unchanged payload). */40  agent?: 'claude' | 'codex';41}4243/** `/a/b_c.d e` → `-a-b-c-d-e` (every non-alphanumeric character becomes a dash). */44export function encodeProjectDir(cwd: string): string {45  return cwd.replace(/[^A-Za-z0-9]/g, '-');46}4748const LABEL = /^[A-Za-z0-9 ._-]{1,40}$/;4950/** Map `claude auth status` output to a boolean + non-sensitive labels. */51export function parseAuthStatus(stdout: string): Pick<ClaudeInfo, 'authenticated' | 'method' | 'subscription'> {52  const unknown = { authenticated: null, method: null, subscription: null };53  const start = stdout.indexOf('{');54  const end = stdout.lastIndexOf('}');55  if (start >= 0 && end > start) {56    try {57      const obj = JSON.parse(stdout.slice(start, end + 1)) as Record<string, unknown>;58      if (typeof obj.loggedIn !== 'boolean') return unknown;59      const label = (v: unknown): string | null => (typeof v === 'string' && LABEL.test(v) ? v : null);60      return { authenticated: obj.loggedIn, method: label(obj.authMethod), subscription: label(obj.subscriptionType) };61    } catch {62      return unknown;63    }64  }65  // Human-readable fallback (older CLIs): only unambiguous phrases are trusted.66  if (/\bnot logged in\b/i.test(stdout)) return { authenticated: false, method: null, subscription: null };67  if (/\blogged in\b/i.test(stdout)) return { authenticated: true, method: null, subscription: null };68  return unknown;69}7071/** Codex profile: `codex --version` + `codex login status` (which prints on stderr). */72async function collectCodexInfo(config: RuntimeConfig, env: ClaudeEnv): Promise<ClaudeInfo> {73  const info: ClaudeInfo = {74    path: (await resolveClaudeBinary(config, env)).path,75    version: null,76    authenticated: null,77    method: null,78    subscription: null,79    configDir: env.codexHome,80    agent: 'codex',81  };82  if (!info.path) return info;83  const [ver, auth] = await Promise.all([84    runClaude(config, env, ['--version'], { timeoutMs: 15_000, maxStdout: 4096 }),85    runClaude(config, env, ['login', 'status'], { timeoutMs: 20_000, maxStdout: 16 * 1024 }),86  ]);87  if (ver && ver.code === 0) info.version = parseCodexVersion(ver.stdout);88  // The status line may end with a masked API key: only the parsed boolean and label are kept.89  if (auth && !auth.timedOut && !auth.spawnError) Object.assign(info, parseCodexLoginStatus(`${auth.stdout}\n${auth.stderr}`));90  return info;91}9293async function collectInfo(config: RuntimeConfig, env: ClaudeEnv): Promise<ClaudeInfo> {94  if (config.agent === 'codex') return collectCodexInfo(config, env);95  const info: ClaudeInfo = {96    path: (await resolveClaudeBinary(config, env)).path,97    version: null,98    authenticated: null,99    method: null,100    subscription: null,101    configDir: env.configDir,102  };103  if (!info.path) return info;104  const [ver, auth] = await Promise.all([105    runClaude(config, env, ['--version'], { timeoutMs: 15_000, maxStdout: 4096 }),106    runClaude(config, env, ['auth', 'status', '--json'], { timeoutMs: 20_000, maxStdout: 64 * 1024 }),107  ]);108  if (ver && ver.code === 0) {109    const m = /\d+\.\d+\.\d+[\w.-]*/.exec(ver.stdout);110    info.version = m ? m[0] : null;111  }112  if (auth && !auth.timedOut && !auth.spawnError) Object.assign(info, parseAuthStatus(auth.stdout));113  return info;114}115116/** Is this transcript text a real prompt typed by the user (not a command echo / injected block)? */117function cleanPrompt(text: string): string | null {118  let t = text.trim();119  // Strip leading injected blocks, then refuse command/meta wrappers entirely.120  for (;;) {121    const m = /^<system-reminder>[\s\S]*?<\/system-reminder>\s*/.exec(t);122    if (!m) break;123    t = t.slice(m[0].length);124  }125  if (t === '') return null;126  if (/^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|system-reminder|bash-input|bash-stdout|bash-stderr|user-memory-input|task-notification)\b/.test(t)) return null;127  if (t.startsWith('Caveat: The messages below were generated by the user while running local commands')) return null;128  if (t.startsWith('[Request interrupted')) return null;129  const flat = t.replace(/\s+/g, ' ');130  return flat.length > SUMMARY_CHARS ? `${flat.slice(0, SUMMARY_CHARS - 1)}…` : flat;131}132133function userPrompt(entry: unknown): string | null {134  if (typeof entry !== 'object' || entry === null) return null;135  const e = entry as Record<string, unknown>;136  if (e.type !== 'user' || e.isMeta === true || e.isSidechain === true || e.isCompactSummary === true) return null;137  const msg = e.message as Record<string, unknown> | undefined;138  if (!msg || msg.role !== 'user') return null;139  if (typeof msg.content === 'string') return cleanPrompt(msg.content);140  if (Array.isArray(msg.content)) {141    for (const block of msg.content as unknown[]) {142      const b = block as Record<string, unknown> | null;143      if (b && b.type === 'text' && typeof b.text === 'string') {144        const p = cleanPrompt(b.text);145        if (p) return p;146      }147    }148  }149  return null;150}151152/** Read at most MAX_TRANSCRIPT_READ bytes; count the lines read and find the first user prompt. */153export async function readTranscriptMeta(file: string, cap = MAX_TRANSCRIPT_READ): Promise<{ summary: string; messageCount: number }> {154  const fh = await fsp.open(file, 'r');155  try {156    const size = (await fh.stat()).size;157    const want = Math.min(size, cap);158    const buf = Buffer.alloc(want);159    let got = 0;160    while (got < want) {161      const { bytesRead } = await fh.read(buf, got, want - got, got);162      if (bytesRead === 0) break;163      got += bytesRead;164    }165    const text = buf.subarray(0, got).toString('utf8');166    const lines = text.split('\n');167    // When the read stopped at the cap, the last piece is an incomplete line: not counted.168    if (got < size) lines.pop();169    let summary = '';170    let count = 0;171    for (const line of lines) {172      if (line.trim() === '') continue;173      count++;174      if (summary !== '' || !line.includes('"user"')) continue;175      try {176        summary = userPrompt(JSON.parse(line)) ?? '';177      } catch {178        /* malformed line: counted, not summarised */179      }180    }181    return { summary, messageCount: count };182  } finally {183    await fh.close();184  }185}186187export async function listConversations(projectsDir: string, cwds: string[]): Promise<ClaudeConversation[]> {188  const found = new Map<string, { file: string; mtimeMs: number }>();189  for (const cwd of new Set(cwds)) {190    const dir = path.join(projectsDir, encodeProjectDir(cwd));191    let names: string[];192    try {193      names = await fsp.readdir(dir);194    } catch {195      continue;196    }197    for (const name of names) {198      if (!name.endsWith('.jsonl')) continue;199      const file = path.join(dir, name);200      const st = await fsp.lstat(file).catch(() => null);201      if (!st?.isFile()) continue; // symlinks and directories are ignored202      const id = name.slice(0, -'.jsonl'.length);203      const prev = found.get(id);204      if (!prev || prev.mtimeMs < st.mtimeMs) found.set(id, { file, mtimeMs: st.mtimeMs });205    }206  }207  const newest = [...found.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs).slice(0, MAX_CONVERSATIONS);208  const out: ClaudeConversation[] = [];209  for (const [id, f] of newest) {210    const meta = await readTranscriptMeta(f.file).catch(() => null);211    if (!meta) continue;212    out.push({ id, summary: meta.summary, updatedAt: new Date(f.mtimeMs).toISOString(), messageCount: meta.messageCount });213  }214  return out;215}216217const ConversationsBody = z.object({ cwd: z.string().min(1).max(4096) });218219export function createClaudePlugin(opts: ClaudeOptions = {}): RuntimePlugin {220  return async (app, { config }) => {221    const env = opts.env ?? defaultClaudeEnv();222    let cached: { at: number; value: Promise<ClaudeInfo> } | null = null;223224    app.get('/claude/info', async () => {225      if (!cached || Date.now() - cached.at > INFO_TTL_MS) {226        cached = { at: Date.now(), value: collectInfo(config, env) };227        cached.value.catch(() => {228          cached = null;229        });230      }231      return cached.value;232    });233234    app.post('/claude/conversations', async (req) => {235      const parsed = ConversationsBody.safeParse(req.body);236      if (!parsed.success) throw new RtError(400, 'bad_request', 'cwd is required');237      const cwd = parsed.data.cwd;238      if (!path.isAbsolute(cwd)) throw new RtError(400, 'bad_request', 'cwd must be absolute');239      const real = await assertRootAllowed(config, cwd);240      // Both CLIs record the process cwd (a real path); the lexical form is tried as well.241      if (config.agent === 'codex') return listCodexSessions(path.join(env.codexHome, 'sessions'), [real, path.resolve(cwd)]);242      return listConversations(path.join(env.configDir, 'projects'), [real, path.resolve(cwd)]);243    });244  };245}246247export default createClaudePlugin();248
Files main
Menu

Theme