在浏览器里用 Claude Code。基于开源的 CloudCLI,加了一组本地补丁:免登录、只看 Claude 会话、标题和客户端一致、额度显示、一键打开文件。下面是复刻方法和全部改动代码。
claude 并用订阅账号登录。git clone https://github.com/Amewiki777/cloudcli-kit.git && cd cloudcli-kit ./install.sh
PROXY=http://127.0.0.1:7897 ./install.sh
脚本依次做的事:装固定版本 1.37.3 → 把 files/ 覆盖进包目录(原文件备份成 *.orig)→ 按你本机的 node / claude 路径生成 launchd 配置并启动 → 注册一个占位用户、跳过首次引导。可以反复运行。
| # | 定制 | 为什么 | 改动位置 |
|---|---|---|---|
| 1 | 免登录,只监听 127.0.0.1 | 原版强制注册账号、默认监听 0.0.0.0。自己本机用,登录是多余的 | .env、dist/index.html |
| 2 | 只显示 Claude 会话 | 原版会把 Codex 等其它工具的会话也混进列表 | 两个 service 文件 |
| 3 | 标题和 Claude 客户端一致,隐藏脚本会话 | 原版取第一条输入当标题,会出现 /model 这种;claude -p 脚本产生的大量一次性会话会刷屏 | claude-session-synchronizer.provider.js |
| 4 | 额度胶囊(右下角) | 随时看 5 小时 / 本周 / 本周 Fable 用量和重置时间,每 60 秒刷新 | 新增 claude-usage.patch.js |
| 5 | 模型标签带版本号 | 原版只写 Opus / Sonnet,分不清是哪一代 | claude-models.provider.js |
| 6 | 文件名旁的 「打开 ▾」菜单 | Claude 改了哪个文件,点一下用默认程序 / 指定编辑器打开、在 Finder 中显示,或预览 Markdown | 新增 local-file.patch.js |
| 7 | 粘贴任意文件 | 原版粘贴只收图片,PDF、表格只能拖拽 | dist/index.html |
| 8 | 公式用 LaTeX | 网页端能渲染 KaTeX,但 Claude 默认按终端习惯写纯文本公式 | claude-runtime.provider.js |
每一项的具体代码见 第 8 节。
.env 里的 HOST=127.0.0.1 一定不要改成 0.0.0.0。如果确实要从手机或别的电脑访问,先恢复登录:删掉 .env 里的 VITE_IS_PLATFORM=true,并把 dist/index.html 恢复成 index.html.orig(会丢掉额度胶囊等前端补丁,需要的话只去掉 <title> 后面那行写 auth-token 的脚本)。
# 看日志 tail -f ~/.cloudcli/server.log # 重启(改了补丁之后) launchctl kickstart -k gui/$(id -u)/com.cloudcli.server # 看 3001 端口被谁占着 lsof -nP -iTCP:3001 -sTCP:LISTEN # 卸载(加 --purge 连 ~/.cloudcli 一起删) ./uninstall.sh
patches.diff 逐个确认能打上,再把数据库里的 scan_state.last_scanned_at 置空触发重扫、重启服务。| 位置 | 内容 |
|---|---|
~/.claude/projects/ | 真正的对话记录(JSONL),终端 claude、CloudCLI、桌面客户端共用 |
~/.cloudcli/auth.db | CloudCLI 自己的索引:用户、会话列表、设置 |
~/.cloudcli/assets/ | 聊天里粘贴 / 上传的图片 |
~/.cloudcli/server.log | 服务日志 |
~/Library/LaunchAgents/com.cloudcli.server.plist | 开机自启配置 |
npm rebuild bcrypt better-sqlite3 node-pty。lsof -nP -t -iTCP:3001 -sTCP:LISTEN 找到 PID 再 kill;pkill -f cloudcli 不一定杀得干净。旧实例占着端口时 launchd 会反复重启。.zshrc、不加载 nvm,plist 里 node 和 claude 的路径必须是绝对路径;需要代理也得在 plist 里单独写。/api/user/complete-onboarding 跳过了。localhost:3001/session/<id> 用的是 CloudCLI 自己的编号。对应关系在 auth.db 的 sessions 表(session_id / provider_session_id / jsonl_path)。claude --chrome。CloudCLI 和桌面客户端读的是同一批对话记录,但客户端左侧列表只显示它自己建过的会话。tools/import_to_claude_desktop.py 会为其余对话补一个列表项(只新建小 JSON 文件,不改对话记录),之后在客户端里也能接着聊。
python3 tools/import_to_claude_desktop.py # 试运行:统计 + 预览标题 python3 tools/import_to_claude_desktop.py --apply # 写入
写入后按 ⌘Q 完全退出 Claude 客户端再打开。标题优先用原有标题,没有就取第一句话的前 50 个字;claude -p 脚本产生的会话、工作目录已删除的会话会自动跳过。撤销:删掉文件里带 "importedBy": "cloudcli-kit" 的 local_*.json。
想自己一步步来,或者不是 macOS:
npm install -g @cloudcli-ai/cloudcli@1.37.3,然后 PKG="$(npm root -g)/@cloudcli-ai/cloudcli"。files/ 下的文件按相同相对路径覆盖进 $PKG(或在 $PKG 里 patch -p1 < patches.diff)。其中 .env 内容:
VITE_IS_PLATFORM=true HOST=127.0.0.1
node "$PKG/dist-server/server/modules/cli/cli.js",打开 localhost:3001。curl -X POST localhost:3001/api/auth/register -H 'Content-Type: application/json' \
-d "{\"username\":\"local\",\"password\":\"$(openssl rand -hex 16)\"}"
curl -X POST localhost:3001/api/user/complete-onboarding -H 'Content-Type: application/json' -d '{}'systemd --user 服务。文件菜单里的「打开」依赖 macOS 的 open 命令,其余功能跨平台。install.sh一键安装脚本全文#!/bin/bash
# CloudCLI 本机定制版一键安装(macOS)
# 用法: ./install.sh # 不走代理
# PROXY=http://127.0.0.1:7897 ./install.sh # Claude 需要走本地代理时
# 做的事: 装 @cloudcli-ai/cloudcli@1.37.3 → 覆盖补丁文件 → 写 launchd 常驻 → 注册占位用户并跳过引导
# 前提: 已装 Node 20+ 和 Claude Code CLI,并且终端里 `claude` 已登录(订阅账号)
set -euo pipefail
VERSION=1.37.3
PORT=3001
LABEL=com.cloudcli.server
KIT="$(cd "$(dirname "$0")" && pwd)"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m错误:\033[0m %s\n' "$*" >&2; exit 1; }
[[ "$(uname)" == Darwin ]] || die "这个脚本只支持 macOS(launchd、open 命令都依赖它)"
NODE="$(command -v node)" || die "找不到 node,请先装 Node 20+(推荐 nvm)"
command -v npm >/dev/null || die "找不到 npm"
CLAUDE="$(command -v claude)" || die "找不到 claude,请先装 Claude Code CLI 并登录"
NODE_MAJOR="$("$NODE" -p 'process.versions.node.split(".")[0]')"
(( NODE_MAJOR >= 20 )) || die "Node 版本太旧($("$NODE" -v)),需要 20 以上"
# 1. 安装固定版本:补丁依赖 1.37.3 的文件结构,别装最新版
say "安装 @cloudcli-ai/cloudcli@$VERSION"
npm install -g "@cloudcli-ai/cloudcli@$VERSION"
PKG="$(npm root -g)/@cloudcli-ai/cloudcli"
[[ "$("$NODE" -p "require('$PKG/package.json').version")" == "$VERSION" ]] || die "装上的版本不是 $VERSION"
# 2. 覆盖补丁文件,原文件第一次覆盖前备份成 *.orig
say "应用补丁到 $PKG"
cd "$KIT/files"
find . -type f | while read -r f; do
dst="$PKG/${f#./}"
mkdir -p "$(dirname "$dst")"
[[ -f "$dst" && ! -f "$dst.orig" ]] && cp "$dst" "$dst.orig"
cp "$f" "$dst"
done
cd "$KIT"
# 3. launchd 常驻。launchd 不读 shell 配置,所以 node / claude 的目录都写成绝对路径
say "写入 $PLIST"
mkdir -p "$HOME/.cloudcli" "$HOME/Library/LaunchAgents"
PATH_VALUE="$(dirname "$NODE"):$(dirname "$CLAUDE"):/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
PROXY_XML=""
if [[ -n "${PROXY:-}" ]]; then
for k in http_proxy https_proxy HTTP_PROXY HTTPS_PROXY; do
PROXY_XML+=" <key>$k</key><string>$PROXY</string>"$'\n'
done
for k in no_proxy NO_PROXY; do
PROXY_XML+=" <key>$k</key><string>localhost,127.0.0.1,::1</string>"$'\n'
done
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$NODE</string>
<string>$PKG/dist-server/server/modules/cli/cli.js</string>
</array>
<key>WorkingDirectory</key><string>$HOME</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>$PATH_VALUE</string>
<key>HOME</key><string>$HOME</string>
$PROXY_XML </dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>StandardOutPath</key><string>$HOME/.cloudcli/server.log</string>
<key>StandardErrorPath</key><string>$HOME/.cloudcli/server.log</string>
</dict>
</plist>
EOF
plutil -lint "$PLIST" >/dev/null
# 端口被旧实例占着时 launchd 会无限重启,先清掉
OLD_PID="$(lsof -nP -t -iTCP:$PORT -sTCP:LISTEN 2>/dev/null || true)"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
[[ -n "$OLD_PID" ]] && { say "结束占用 $PORT 端口的旧进程 $OLD_PID"; kill $OLD_PID 2>/dev/null || true; sleep 1; }
launchctl bootstrap "gui/$(id -u)" "$PLIST"
say "等待服务启动"
for _ in $(seq 1 30); do
curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break
sleep 1
done
curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 || die "服务没起来,看日志: tail -50 ~/.cloudcli/server.log"
# 4. 注册占位用户(免登录模式下所有请求都映射到第一个用户,密码随机生成后不再使用)
say "注册占位用户 local 并跳过首次引导"
PW="$(openssl rand -hex 16)"
curl -fsS -X POST "http://127.0.0.1:$PORT/api/auth/register" \
-H 'Content-Type: application/json' -d "{\"username\":\"local\",\"password\":\"$PW\"}" >/dev/null 2>&1 \
|| echo " (已有用户,跳过注册)"
curl -fsS -X POST "http://127.0.0.1:$PORT/api/user/complete-onboarding" \
-H 'Authorization: Bearer local' -H 'Content-Type: application/json' -d '{}' >/dev/null 2>&1 || true
say "完成 → 打开 http://localhost:$PORT"
echo " 日志: tail -f ~/.cloudcli/server.log"
echo " 重启: launchctl kickstart -k gui/\$(id -u)/$LABEL"
echo " 卸载: ./uninstall.sh"
open "http://localhost:$PORT" || true
相对原版 1.37.3 的完整 diff:7 个改动文件 + 2 个新增文件,点开查看。压缩包里 patches.diff 是同一份内容,files/ 是打完补丁的完整文件。
dist-server/server/index.js+28 −0挂载 /api/claude-usage 和 /api/local-file/* 路由--- a/dist-server/server/index.js +++ b/dist-server/server/index.js @@ -33,6 +33,8 @@ import { browserUseService } from './modules/browser-use/browser-use.service.js'; import { initializeDatabase, sessionsDb } from './modules/database/index.js'; import { configureWebPush } from './modules/notifications/index.js'; +import { getClaudeUsage } from './claude-usage.patch.js'; // PATCHED (local) +import { listApps, openTarget, renderPreview } from './local-file.patch.js'; // PATCHED (local) const __dirname = getModuleDirectory(import.meta.url); // The server source runs from /server, while the compiled output runs from /dist-server/server. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. \ No newline at end of file @@ -152,6 +154,32 @@ // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); app.use('/api/voice', authenticateToken, voiceRoutes); +// PATCHED (local): Claude plan usage meter for the web UI widget. +app.get('/api/claude-usage', async (req, res) => { + try { + const u = await getClaudeUsage(req.query.force === '1'); + const rl = u.rate_limits || {}; + res.json({ + fetchedAt: u.fetchedAt, cached: Boolean(u.cached), subscription_type: u.subscription_type, + available: Boolean(u.rate_limits_available), + limits: Array.isArray(rl.limits) ? rl.limits : null, + five_hour: rl.five_hour ?? null, seven_day: rl.seven_day ?? null, + extra_usage: rl.extra_usage ? { is_enabled: rl.extra_usage.is_enabled, utilization: rl.extra_usage.utilization } : null, + }); + } catch (error) { + res.status(502).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); +// PATCHED (local): open local files from the chat UI. +app.get('/api/local-file/apps', (_req, res) => { res.json({ apps: listApps() }); }); +app.post('/api/local-file/open', express.json(), async (req, res) => { + try { res.json(await openTarget({ path: req.body?.path, mode: req.body?.mode, app: req.body?.app, dry: req.body?.dry === true })); } + catch (e) { res.status(e.status || 500).json({ error: e.message }); } +}); +app.get('/api/local-file/preview', (req, res) => { + try { res.type('html').send(renderPreview(String(req.query.path || ''))); } + catch (e) { res.status(e.status || 500).type('text').send(e.message); } +}); // Serve public files (like api-docs.html) app.use(express.static(path.join(APP_ROOT, 'public'))); // Static files served after API routes \ No newline at end of file
dist-server/server/modules/providers/list/claude/claude-models.provider.js+9 −8模型标签带版本号--- a/dist-server/server/modules/providers/list/claude/claude-models.provider.js +++ b/dist-server/server/modules/providers/list/claude/claude-models.provider.js @@ -1,3 +1,4 @@ +// PATCHED (local): model labels carry version numbers (aliases unchanged). import { readFile } from 'node:fs/promises'; import { sessionsDb } from '../../../../modules/database/index.js'; import { buildDefaultProviderCurrentActiveModel } from '../../../../shared/utils.js'; \ No newline at end of file @@ -31,7 +32,7 @@ { value: 'best', label: 'Best available', - description: 'Use Fable 5 when available, otherwise the latest Opus model.', + description: 'Use Fable 5.1 when available, otherwise Opus 5.5.', effort: { default: 'high', values: [ \ No newline at end of file @@ -46,7 +47,7 @@ }, { value: 'fable', - label: 'Fable 5', + label: 'Fable 5.1', description: 'Most capable Claude model for the hardest, longest-running tasks.', effort: { default: 'high', \ No newline at end of file @@ -62,7 +63,7 @@ }, { value: 'sonnet', - label: 'Sonnet', + label: 'Sonnet 5', description: 'Latest Sonnet model for everyday coding tasks.', effort: { default: 'high', \ No newline at end of file @@ -78,7 +79,7 @@ }, { value: 'sonnet[1m]', - label: 'Sonnet (1M context)', + label: 'Sonnet 5 (1M context)', description: 'Latest Sonnet model with a 1M context window.', effort: { default: 'high', \ No newline at end of file @@ -94,7 +95,7 @@ }, { value: 'opus', - label: 'Opus', + label: 'Opus 5.5', description: 'Latest Opus model for complex reasoning and coding tasks.', effort: { default: 'high', \ No newline at end of file @@ -110,7 +111,7 @@ }, { value: 'opus[1m]', - label: 'Opus (1M context)', + label: 'Opus 5.5 (1M context)', description: 'Latest Opus model with a 1M context window.', effort: { default: 'high', \ No newline at end of file @@ -126,12 +127,12 @@ }, { value: 'haiku', - label: 'Haiku', + label: 'Haiku 4.5', description: 'Fast and efficient Claude model for simple tasks.', }, { value: 'opusplan', - label: 'Opus Plan', + label: 'Opus 5.5 Plan', description: 'Use Opus while planning, then switch to Sonnet for execution.', effort: { default: 'high', \ No newline at end of file
dist-server/server/modules/providers/list/claude/claude-runtime.provider.js+9 −1系统提示追加:公式用 LaTeX--- a/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +++ b/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js @@ -219,9 +219,17 @@ sdkOptions.disallowedTools = settings.disallowedTools || []; sdkOptions.model = options.model || CLAUDE_PREDEFINED_MODELS.DEFAULT; applyClaudeEffort(sdkOptions, resolveClaudeEffort(sdkOptions.model, effort, options.effortModels || CLAUDE_PREDEFINED_MODELS)); + // PATCHED (local): the web UI renders Markdown + KaTeX, so ask for LaTeX math instead of plain-text formulas. + const CLOUDCLI_MATH_NOTE = [ + 'Rendering note: your replies are displayed in a web chat UI (CloudCLI) that renders GitHub-flavored Markdown with KaTeX, not in a terminal.', + 'Write ALL mathematics as LaTeX: inline math as $...$ and display equations as $$...$$ on their own lines.', + 'Never put formulas in plain-text or ```text code blocks, and do not substitute Unicode pseudo-math (Σ, ∫, ², ≈, ·) for LaTeX.', + 'Code blocks are only for code, commands and file contents. Tables, headings, lists and Mermaid diagrams render normally.', + ].join(' '); sdkOptions.systemPrompt = { type: 'preset', - preset: 'claude_code' + preset: 'claude_code', + append: CLOUDCLI_MATH_NOTE }; sdkOptions.settingSources = ['project', 'user', 'local']; // The SDK resumes with the provider-native session id, never the app id. \ No newline at end of file
dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js+84 −96标题规则与客户端一致,隐藏 claude -p 脚本会话--- a/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +++ b/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js @@ -1,141 +1,129 @@ +// PATCHED (local): title priority = Claude custom-title > ai-title > first real prompt. +// Original kept as *.orig. Only Claude sessions are indexed. import os from 'node:os'; import path from 'node:path'; +import fs from 'node:fs'; +import readline from 'node:readline'; import { readFile } from 'node:fs/promises'; import { sessionsDb } from '../../../../modules/database/index.js'; -import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfter, normalizeSessionName, readFileTimestamps, } from '../../../../shared/utils.js'; -/** - * Session indexer for Claude transcript artifacts. - */ +import { extractFirstValidJsonlData, findFilesRecursivelyCreatedAfter, normalizeSessionName, readFileTimestamps, } from '../../../../shared/utils.js'; +const isMeaningfulPrompt = (text) => { + const t = typeof text === 'string' ? text.trim() : ''; + return t.length > 0 && !t.startsWith('/') && !t.startsWith('<'); +}; +const textOfUserMessage = (message) => { + if (!message || message.role !== 'user') return undefined; + const c = message.content; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + for (const block of c) { + if (block && block.type === 'text' && typeof block.text === 'string') return block.text; + } + } + return undefined; +}; +async function buildFirstPromptMap(filePath) { + const lookup = new Map(); + lookup.interactiveIds = new Set(); + try { + const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity }); + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed; + try { parsed = JSON.parse(trimmed); } catch { continue; } + const key = parsed.sessionId; + const value = parsed.display; + if (typeof key === 'string') lookup.interactiveIds.add(key); + if (typeof key === 'string' && isMeaningfulPrompt(value) && !lookup.has(key)) { + lookup.set(key, value.trim()); + } + } + } catch { /* history.jsonl missing */ } + return lookup; +} export class ClaudeSessionSynchronizer { provider = 'claude'; claudeHome = path.join(os.homedir(), '.claude'); - /** - * Returns true when a JSONL file is a subagent transcript or tool result - * rather than a top-level session. - * - * Claude stores subagent transcripts under a `subagents/` directory and - * tool results under a `tool-results/` directory, e.g. - * `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`. - * Those files repeat the parent session's `sessionId`, so indexing them as - * standalone sessions overwrites the parent row's `jsonl_path` and corrupts - * the main session record. The recursive scan in `synchronize()` reaches - * them, so both entry points must skip them. - */ isSubagentTranscript(filePath) { const pathParts = path.normalize(filePath).split(path.sep); return pathParts.includes('subagents') || pathParts.includes('tool-results'); } - /** - * Scans ~/.claude/projects and upserts discovered sessions into DB. - */ async synchronize(since) { - const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); + const nameMap = await buildFirstPromptMap(path.join(this.claudeHome, 'history.jsonl')); const files = await findFilesRecursivelyCreatedAfter(path.join(this.claudeHome, 'projects'), '.jsonl', since ?? null); let processed = 0; for (const filePath of files) { - if (this.isSubagentTranscript(filePath)) { - continue; - } + if (this.isSubagentTranscript(filePath)) continue; const parsed = await this.processSessionFile(filePath, nameMap); - if (!parsed) { - continue; - } + if (!parsed) continue; const timestamps = await readFileTimestamps(filePath); sessionsDb.createSession(parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, filePath); processed += 1; } return processed; } - /** - * Parses and upserts one Claude session JSONL file. - */ async synchronizeFile(filePath) { - if (!filePath.endsWith('.jsonl')) { - return null; - } - if (this.isSubagentTranscript(filePath)) { - return null; - } - const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); + if (!filePath.endsWith('.jsonl')) return null; + if (this.isSubagentTranscript(filePath)) return null; + const nameMap = await buildFirstPromptMap(path.join(this.claudeHome, 'history.jsonl')); const parsed = await this.processSessionFile(filePath, nameMap); - if (!parsed) { - return null; - } + if (!parsed) return null; const timestamps = await readFileTimestamps(filePath); return sessionsDb.createSession(parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, filePath); } - /** - * Extracts session metadata from one Claude JSONL session file. - */ async processSessionFile(filePath, nameMap) { const parsed = await extractFirstValidJsonlData(filePath, (rawData) => { const data = rawData; const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined; - if (!sessionId || !projectPath) { - return null; - } - return { - sessionId, - projectPath, - }; + if (!sessionId || !projectPath) return null; + return { sessionId, projectPath }; }); - if (!parsed) { - return null; - } - // App-created sessions are keyed by an app id, so disk-discovered provider - // ids must be resolved through the provider-id mapping first. - const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) - ?? sessionsDb.getSessionById(parsed.sessionId); - const existingSessionName = existingSession?.custom_name; - if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { - return { - ...parsed, - sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'), - }; - } - let sessionName = nameMap.get(parsed.sessionId); - if (!sessionName) { - sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId); - } - return { - ...parsed, - sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'), - }; + if (!parsed) return null; + const titles = await this.extractTitlesFromTranscript(filePath, parsed.sessionId); + // PATCHED (local): skip headless script sessions (claude -p / SDK one-shots). + // A real conversation has a desktop custom-title, an ai-title, an interactive + // "mode" record, an entry in history.jsonl, or was created by this app. + const existingRow = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) ?? sessionsDb.getSessionById(parsed.sessionId); + const createdByApp = Boolean(existingRow && existingRow.session_id !== existingRow.provider_session_id); + const interactive = Boolean(titles.customTitle || titles.aiTitle || titles.hasMode || nameMap.interactiveIds?.has(parsed.sessionId) || createdByApp); + if (!interactive) return null; + const sessionName = titles.customTitle || titles.aiTitle || nameMap.get(parsed.sessionId) || titles.firstPrompt; + return { ...parsed, sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session') }; } - async extractSessionAiTitleFromEnd(filePath, sessionId) { + async extractTitlesFromTranscript(filePath, sessionId) { + const result = { customTitle: undefined, aiTitle: undefined, firstPrompt: undefined, hasMode: false }; try { const content = await readFile(filePath, 'utf8'); const lines = content.split(/\r?\n/); for (let index = lines.length - 1; index >= 0; index -= 1) { + if (result.customTitle && result.aiTitle && result.hasMode) break; const line = lines[index]?.trim(); - if (!line) { - continue; + if (!line) continue; + let data; + try { data = JSON.parse(line); } catch { continue; } + if (typeof data.sessionId === 'string' && data.sessionId !== sessionId) continue; + if (data.type === 'mode' || data.type === 'permission-mode') result.hasMode = true; + if (!result.customTitle && data.type === 'custom-title' && typeof data.customTitle === 'string' && data.customTitle.trim()) { + result.customTitle = data.customTitle.trim(); } - let parsed; - try { - parsed = JSON.parse(line); + if (!result.aiTitle && data.type === 'ai-title' && typeof data.aiTitle === 'string' && data.aiTitle.trim()) { + result.aiTitle = data.aiTitle.trim(); } - catch { - continue; + } + if (!result.customTitle && !result.aiTitle) { + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + let data; + try { data = JSON.parse(line); } catch { continue; } + if (data.type !== 'user' || data.isSidechain) continue; + const text = textOfUserMessage(data.message); + if (isMeaningfulPrompt(text)) { result.firstPrompt = text.trim().slice(0, 200); break; } } - const data = parsed; - const eventType = typeof data.type === 'string' ? data.type : undefined; - const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; - const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined; - const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; - const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined; - if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || - (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) || - (eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim())) { - return aiTitle || lastPrompt || claudeRenamedTitle; - } } - } - catch { - // Ignore missing/unreadable files so sync can continue. - } - return undefined; + } catch { /* unreadable file: fall through */ } + return result; } } -//# sourceMappingURL=claude-session-synchronizer.provider.js.map \ No newline at end of file
dist-server/server/modules/providers/services/session-synchronizer.service.js+1 −1同步时只处理 claude provider--- a/dist-server/server/modules/providers/services/session-synchronizer.service.js +++ b/dist-server/server/modules/providers/services/session-synchronizer.service.js @@ -69,7 +69,7 @@ opencode: 0, }; const failures = []; - const results = await Promise.allSettled(providerRegistry.listProviders().map(async (provider) => ({ + const results = await Promise.allSettled(providerRegistry.listProviders().filter((provider) => provider.id === "claude") /* PATCHED (local) */ .map(async (provider) => ({ provider: provider.id, processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined), }))); \ No newline at end of file
dist-server/server/modules/providers/services/sessions-watcher.service.js+3 −1文件监听只看 ~/.claude/projects--- a/dist-server/server/modules/providers/services/sessions-watcher.service.js +++ b/dist-server/server/modules/providers/services/sessions-watcher.service.js @@ -4,7 +4,7 @@ import chokidar from 'chokidar'; import { sessionSynchronizerService } from '../../../modules/providers/services/session-synchronizer.service.js'; import { broadcastSessionUpsertedBatch } from '../../../modules/websocket/index.js'; -const PROVIDER_WATCH_PATHS = [ +const PROVIDER_WATCH_PATHS_ALL = [ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects'), \ No newline at end of file @@ -22,6 +22,8 @@ rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'), }, ]; +// PATCHED (local): only watch Claude transcripts. +const PROVIDER_WATCH_PATHS = PROVIDER_WATCH_PATHS_ALL.filter((entry) => entry.provider === "claude"); const WATCHER_IGNORED_PATTERNS = [ '**/node_modules/**', '**/.git/**', \ No newline at end of file
dist/index.html+135 −0注入占位 token(免登录)+ 额度胶囊 + 文件「打开 ▾」菜单 + 粘贴非图片文件--- a/dist/index.html +++ b/dist/index.html @@ -6,6 +6,7 @@ <link rel="icon" type="image/png" href="/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" /> <title>CloudCLI UI</title> + <script>try{localStorage.getItem("auth-token")||localStorage.setItem("auth-token","eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoibG9jYWwiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMH0.local")}catch(e){}</script> <!-- Fonts: Encode Sans (UI) + Merriweather (chat) --> <link rel="preconnect" href="https://fonts.googleapis.com" /> @@ -38,6 +39,140 @@ <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CYrSt7-U.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CS4rQ5Mr.js"> <link rel="stylesheet" crossorigin href="/assets/index-BTDHQ6Id.css"> + + <!-- PATCH:claude-usage-widget --> + <style> + #cu-widget{position:fixed;right:14px;bottom:14px;z-index:9999;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Encode Sans",sans-serif;color:#1f2937} + #cu-widget .cu-pill{display:flex;gap:10px;align-items:center;padding:6px 12px;border-radius:999px;background:rgba(255,255,255,.92);border:1px solid rgba(0,0,0,.12);box-shadow:0 2px 10px rgba(0,0,0,.12);cursor:pointer;backdrop-filter:blur(8px);white-space:nowrap} + #cu-widget .cu-pill span b{font-weight:600} + #cu-widget .cu-panel{display:none;position:absolute;right:0;bottom:36px;width:280px;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.97);border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 24px rgba(0,0,0,.16)} + #cu-widget.open .cu-panel{display:block} + #cu-widget .cu-row{margin:8px 0} + #cu-widget .cu-row .cu-l{display:flex;justify-content:space-between;margin-bottom:3px} + #cu-widget .cu-bar{height:6px;border-radius:3px;background:rgba(0,0,0,.08);overflow:hidden} + #cu-widget .cu-bar i{display:block;height:100%;border-radius:3px;background:#3b82f6} + #cu-widget .warning i,#cu-widget .warning .cu-dot{background:#f59e0b} + #cu-widget .critical i,#cu-widget .critical .cu-dot{background:#ef4444} + #cu-widget .cu-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#3b82f6;margin-right:5px;vertical-align:1px} + #cu-widget .cu-meta{margin-top:8px;font-size:11px;color:#6b7280;display:flex;justify-content:space-between} + #cu-widget .cu-meta a{color:#6b7280;cursor:pointer;text-decoration:underline} + @media (prefers-color-scheme:dark){#cu-widget{color:#e5e7eb}#cu-widget .cu-pill,#cu-widget .cu-panel{background:rgba(24,24,27,.94);border-color:rgba(255,255,255,.14)}#cu-widget .cu-bar{background:rgba(255,255,255,.12)}#cu-widget .cu-meta,#cu-widget .cu-meta a{color:#9ca3af}} + .dark #cu-widget{color:#e5e7eb}.dark #cu-widget .cu-pill,.dark #cu-widget .cu-panel{background:rgba(24,24,27,.94);border-color:rgba(255,255,255,.14)}.dark #cu-widget .cu-bar{background:rgba(255,255,255,.12)}.dark #cu-widget .cu-meta,.dark #cu-widget .cu-meta a{color:#9ca3af} + </style> + <script> + (function(){ + var POLL=60000, el, pill, panel, timer; + function label(r){ if(r.kind==='session')return '5 小时'; if(r.kind==='weekly_all')return '本周 · 全部模型'; if(r.kind==='weekly_scoped'){var s=r.scope||{};var n=(s.model&&s.model.display_name)||(s.surface&&s.surface.display_name)||'';return '本周 · '+n;} return r.kind; } + function short(r){ if(r.kind==='session')return '5h'; if(r.kind==='weekly_all')return '周'; if(r.kind==='weekly_scoped'){var s=r.scope||{};return (s.model&&s.model.display_name)||(s.surface&&s.surface.display_name)||'周*';} return r.kind; } + function resetText(iso){ if(!iso)return ''; var d=new Date(iso), ms=d-Date.now(); if(ms<=0)return '即将重置'; var h=Math.floor(ms/3600000), m=Math.floor(ms%3600000/60000); if(h>=48)return Math.floor(h/24)+' 天后重置'; return (h?h+' 小时 ':'')+m+' 分后重置'; } + function render(d){ + var rows=(d.limits||[]).slice(); + if(!rows.length){ pill.innerHTML='<span>额度:不可用</span>'; panel.innerHTML='<div>'+(d.available===false?'当前登录方式不适用套餐额度':'暂无数据')+'</div>'; return; } + pill.innerHTML=rows.map(function(r){return '<span class="'+r.severity+'"><i class="cu-dot"></i>'+short(r)+' <b>'+r.percent+'%</b></span>';}).join(''); + panel.innerHTML=rows.map(function(r){return '<div class="cu-row '+r.severity+'"><div class="cu-l"><span>'+label(r)+'</span><span><b>'+r.percent+'%</b></span></div><div class="cu-bar"><i style="width:'+Math.min(100,r.percent)+'%"></i></div><div style="font-size:11px;color:#6b7280;margin-top:2px">'+resetText(r.resets_at)+'</div></div>';}).join('') + +'<div class="cu-meta"><span>'+(d.subscription_type?('套餐 '+d.subscription_type.toUpperCase()):'')+' · '+new Date(d.fetchedAt).toLocaleTimeString()+'</span><a data-refresh>刷新</a></div>'; + } + function load(force){ + fetch('/api/claude-usage'+(force?'?force=1':'')).then(function(r){return r.json();}).then(render).catch(function(){ pill.innerHTML='<span>额度:读取失败</span>'; }); + } + function init(){ + el=document.createElement('div'); el.id='cu-widget'; + el.innerHTML='<div class="cu-panel"></div><div class="cu-pill"><span>额度加载中…</span></div>'; + document.body.appendChild(el); pill=el.querySelector('.cu-pill'); panel=el.querySelector('.cu-panel'); + pill.addEventListener('click',function(){ el.classList.toggle('open'); }); + panel.addEventListener('click',function(e){ if(e.target&&e.target.hasAttribute('data-refresh')){ load(true); } }); + load(false); timer=setInterval(function(){ if(!document.hidden) load(false); },POLL); + document.addEventListener('visibilitychange',function(){ if(!document.hidden) load(false); }); + } + if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init); else init(); + })(); + </script> + <!-- /PATCH:claude-usage-widget --> + + <!-- PATCH:file-actions --> + <style> + .cu-fa-btn{display:inline-flex;align-items:center;gap:3px;margin-left:6px;padding:0 6px;height:18px;border-radius:9px;border:1px solid rgba(0,0,0,.15);background:rgba(0,0,0,.04);font-size:10px;line-height:1;color:#374151;cursor:pointer;vertical-align:middle;white-space:nowrap} + .cu-fa-btn:hover{background:rgba(59,130,246,.12);border-color:rgba(59,130,246,.4);color:#1d4ed8} + .dark .cu-fa-btn{border-color:rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:#d1d5db}.dark .cu-fa-btn:hover{color:#93c5fd} + .cu-fa-menu{position:fixed;z-index:10000;min-width:200px;padding:6px;border-radius:10px;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 8px 28px rgba(0,0,0,.18);font:13px/1.4 -apple-system,BlinkMacSystemFont,"Encode Sans",sans-serif;color:#1f2937} + .dark .cu-fa-menu{background:#1c1c20;border-color:rgba(255,255,255,.14);color:#e5e7eb} + .cu-fa-menu .cu-fa-path{padding:4px 8px 6px;font-size:11px;color:#6b7280;word-break:break-all;border-bottom:1px solid rgba(128,128,128,.2);margin-bottom:4px} + .cu-fa-menu .cu-fa-item{display:block;width:100%;text-align:left;padding:6px 8px;border-radius:6px;background:none;border:0;color:inherit;font:inherit;cursor:pointer} + .cu-fa-menu .cu-fa-item:hover{background:rgba(59,130,246,.12)} + .cu-fa-menu .cu-fa-item[disabled]{opacity:.5;cursor:default} + </style> + <script> + (function(){ + var apps=null, menu=null, FIBER_PREFIX='__reactFiber'+String.fromCharCode(36); + function getApps(cb){ if(apps){cb(apps);return;} fetch('/api/local-file/apps').then(function(r){return r.json();}).then(function(d){apps=d.apps||[];cb(apps);}).catch(function(){apps=[];cb(apps);}); } + function fiberOf(el){ for(var k in el){ if(k.indexOf(FIBER_PREFIX)===0) return el[k]; } return null; } + function findTool(el){ var f=fiberOf(el), n=0; while(f&&n<30){ var p=f.memoizedProps; if(p&&p.toolInput&&typeof p.toolInput==='object'){ var ti=p.toolInput; var fp=ti.file_path||ti.notebook_path||ti.path; if(typeof fp==='string'&&fp) return {path:fp,toolName:p.toolName,onFileOpen:typeof p.onFileOpen==='function'?p.onFileOpen:null}; return null; } f=f.return; n++; } return null; } + function post(body){ return fetch('/api/local-file/open',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json();}).then(function(d){ if(d.error) alert('打开失败:'+d.error); }); } + function closeMenu(){ if(menu){menu.remove();menu=null;} } + document.addEventListener('click',function(e){ if(menu&&!menu.contains(e.target)) closeMenu(); },true); + document.addEventListener('keydown',function(e){ if(e.key==='Escape') closeMenu(); }); + function openMenu(btn,info){ + closeMenu(); var r=btn.getBoundingClientRect(); menu=document.createElement('div'); menu.className='cu-fa-menu'; + var isMd=/\.(md|markdown|mdx|txt)$/i.test(info.path); + function item(label,fn,dis){ var b=document.createElement('button'); b.className='cu-fa-item'; b.textContent=label; if(dis) b.disabled=true; b.addEventListener('click',function(ev){ev.stopPropagation(); closeMenu(); fn();}); menu.appendChild(b); } + var pathEl=document.createElement('div'); pathEl.className='cu-fa-path'; pathEl.textContent=info.path; menu.appendChild(pathEl); + item('直接打开(默认程序)',function(){post({path:info.path,mode:'default'});}); + getApps(function(list){ list.forEach(function(a){ item('用 '+a+' 打开',function(){post({path:info.path,mode:'app',app:a});}); }); }); + item('在 CloudCLI 编辑器中打开',function(){ info.onFileOpen(info.path); },!info.onFileOpen); + if(isMd) item('预览 Markdown(新标签页)',function(){ window.open('/api/local-file/preview?path='+encodeURIComponent(info.path),'_blank'); }); + item('在访达中显示',function(){post({path:info.path,mode:'reveal'});}); + item('复制路径',function(){ try{navigator.clipboard.writeText(info.path);}catch(e){} }); + document.body.appendChild(menu); + var mw=menu.offsetWidth, mh=menu.offsetHeight, x=Math.min(r.left, window.innerWidth-mw-8), y=r.bottom+4; if(y+mh>window.innerHeight-8) y=r.top-mh-4; + menu.style.left=Math.max(8,x)+'px'; menu.style.top=Math.max(8,y)+'px'; + } + function placeButton(root,info){ + var base=info.path.split('/').pop(), anchor=null, els=root.querySelectorAll('span,button,code,div,a'); + for(var i=0;i<els.length;i++){ var t=(els[i].textContent||'').trim(); if(els[i].children.length===0&&(t===base||t===info.path)){ anchor=els[i]; break; } } + var btn=document.createElement('button'); btn.className='cu-fa-btn'; btn.type='button'; btn.title=info.path; btn.textContent='打开 ▾'; + btn.addEventListener('click',function(e){ e.preventDefault(); e.stopPropagation(); openMenu(btn,info); }); + if(anchor){ anchor.insertAdjacentElement('afterend',btn); } else { (root.firstElementChild||root).appendChild(btn); } + } + function scan(){ + var nodes=document.querySelectorAll('div[class*="border-l-2"]:not([data-cu-fa]),div.group:not([data-cu-fa])'); + for(var i=0;i<nodes.length;i++){ var el=nodes[i]; var info=findTool(el); if(!info){ el.setAttribute('data-cu-fa','none'); continue; } if(!/^(Write|Edit|MultiEdit|Read|NotebookEdit|ApplyPatch)$/.test(info.toolName||'')) { el.setAttribute('data-cu-fa','skip'); continue; } el.setAttribute('data-cu-fa','1'); placeButton(el,info); } + } + var timer=null; function schedule(){ if(timer) return; timer=setTimeout(function(){timer=null; try{scan();}catch(e){}},250); } + function init(){ new MutationObserver(schedule).observe(document.body,{childList:true,subtree:true}); schedule(); } + if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init); else init(); + })(); + </script> + <!-- /PATCH:file-actions --> + + <!-- PATCH:paste-files --> + <script> + (function(){ + // Route non-image files pasted from Finder into the chat composer's dropzone (the app's own paste handler only accepts images). + function composerInput(){ + var ta=document.querySelector('textarea'); + var scope=ta?(ta.closest('form')||ta.parentElement&&ta.parentElement.parentElement&&ta.parentElement.parentElement.parentElement||document):document; + var inputs=Array.prototype.slice.call((scope||document).querySelectorAll('input[type="file"]')); + if(!inputs.length) inputs=Array.prototype.slice.call(document.querySelectorAll('input[type="file"]')); + // react-dropzone input: tabindex=-1 and clip style, not the file-tree "hidden" upload input + var dz=inputs.filter(function(i){return i.getAttribute('tabindex')==='-1'&&!i.classList.contains('hidden');}); + return dz[0]||inputs[0]||null; + } + document.addEventListener('paste',function(e){ + var cd=e.clipboardData; if(!cd||!cd.files||!cd.files.length) return; + var files=Array.prototype.slice.call(cd.files); + var nonImage=files.filter(function(f){return !(f.type||'').startsWith('image/');}); + if(!nonImage.length) return; // images: let the app handle + var input=composerInput(); if(!input) return; + e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); + try{ + var dt=new DataTransfer(); files.forEach(function(f){dt.items.add(f);}); + input.files=dt.files; + input.dispatchEvent(new Event('change',{bubbles:true})); + }catch(err){ console.warn('[paste-files] failed',err); } + },true); + })(); + </script> + <!-- /PATCH:paste-files --> </head> <body> <div id="root"></div>
dist-server/server/claude-usage.patch.js+31 −0新增:通过 Agent SDK 读订阅额度,缓存 60 秒--- /dev/null +++ b/dist-server/server/claude-usage.patch.js @@ -0,0 +1,31 @@ +// PATCHED (local): Claude plan usage meter for the web UI. +// Uses the Agent SDK's /usage control request, so the Claude CLI reads its own +// login credentials; this module never touches them. +import { query } from '@anthropic-ai/claude-agent-sdk'; +import os from 'node:os'; +const CACHE_MS = 60_000; +let cache = { at: 0, data: null }; +let inFlight = null; +async function* never() { await new Promise(() => {}); } +async function fetchUsage() { + const q = query({ prompt: never(), options: { cwd: os.homedir(), permissionMode: 'default', allowedTools: [], maxTurns: 1 } }); + try { + const u = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }); + return { fetchedAt: new Date().toISOString(), subscription_type: u.subscription_type ?? null, rate_limits_available: u.rate_limits_available, rate_limits: u.rate_limits ?? null }; + } finally { + // Only close. interrupt() after close rejects asynchronously and would + // take the whole server down as an unhandled rejection. + try { const r = q.close?.(); if (r && typeof r.catch === 'function') r.catch(() => {}); } catch { /* ignore */ } + } +} +export async function getClaudeUsage(force = false) { + if (!force && cache.data && Date.now() - cache.at < CACHE_MS) return { ...cache.data, cached: true }; + if (!inFlight) { + inFlight = fetchUsage().then((data) => { cache = { at: Date.now(), data }; return data; }).finally(() => { inFlight = null; }); + } + return inFlight; +} +if (process.argv[1] && process.argv[1].endsWith('claude-usage.patch.js')) { + const t0 = Date.now(); + getClaudeUsage(true).then((d) => { console.log(JSON.stringify({ ms: Date.now() - t0, ...d }, null, 1)); process.exit(0); }, (e) => { console.error('ERR', e?.message); process.exit(1); }); +}
dist-server/server/local-file.patch.js+62 −0新增:检测编辑器、用 open 打开文件、Markdown 预览--- /dev/null +++ b/dist-server/server/local-file.patch.js @@ -0,0 +1,62 @@ +// PATCHED (local): open local files from the chat UI (default app / chosen app / Finder / markdown preview). +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFile } from 'node:child_process'; +import { marked } from 'marked'; +const CANDIDATE_APPS = ['Typora', 'TextEdit', 'Visual Studio Code', 'Cursor', 'Sublime Text', 'Obsidian', 'Zed', 'Xcode', 'BBEdit', 'MacDown', 'iA Writer', 'Nova', 'TextMate']; +const APP_DIRS = ['/Applications', '/System/Applications', path.join(os.homedir(), 'Applications')]; +export function listApps() { + const found = []; + for (const name of CANDIDATE_APPS) { + for (const dir of APP_DIRS) { + if (fs.existsSync(path.join(dir, `${name}.app`))) { found.push(name); break; } + } + } + return found; +} +function resolveTarget(raw) { + if (typeof raw !== 'string' || !raw.trim()) throw Object.assign(new Error('path required'), { status: 400 }); + let p = raw.trim(); + if (p.startsWith('~/')) p = path.join(os.homedir(), p.slice(2)); + if (!path.isAbsolute(p)) throw Object.assign(new Error('path must be absolute'), { status: 400 }); + p = path.normalize(p); + if (!fs.existsSync(p)) throw Object.assign(new Error('file not found: ' + p), { status: 404 }); + return p; +} +function run(args) { + return new Promise((resolve, reject) => { + execFile('/usr/bin/open', args, { timeout: 15000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve((stdout || '').trim())); + }); +} +export async function openTarget({ path: raw, mode = 'default', app, dry }) { + const target = resolveTarget(raw); + let args; + if (mode === 'reveal') args = ['-R', target]; + else if (mode === 'app') { + if (!listApps().includes(app)) throw Object.assign(new Error('unknown app: ' + app), { status: 400 }); + args = ['-a', app, target]; + } else args = [target]; + if (dry) return { ok: true, dry: true, command: ['open', ...args] }; + await run(args); + return { ok: true, command: ['open', ...args] }; +} +const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +export function renderPreview(raw) { + const target = resolveTarget(raw); + const stat = fs.statSync(target); + if (!stat.isFile()) throw Object.assign(new Error('not a file'), { status: 400 }); + if (stat.size > 5 * 1024 * 1024) throw Object.assign(new Error('file too large for preview'), { status: 413 }); + const text = fs.readFileSync(target, 'utf8'); + const ext = path.extname(target).toLowerCase(); + const isMd = ext === '.md' || ext === '.markdown' || ext === '.mdx'; + const body = isMd ? marked.parse(text) : `<pre>${esc(text)}</pre>`; + const name = path.basename(target); + return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(name)}</title> +<style>:root{color-scheme:light dark}body{max-width:860px;margin:32px auto;padding:0 20px 60px;font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;color:#1f2937;background:#fff} +@media(prefers-color-scheme:dark){body{color:#e5e7eb;background:#111318}pre,code{background:#1f2329!important}a{color:#93c5fd}th,td{border-color:#374151!important}blockquote{border-color:#4b5563;color:#9ca3af}} +h1,h2,h3{line-height:1.3}pre{background:#f3f4f6;padding:12px 14px;border-radius:8px;overflow:auto;font-size:13px}code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:.92em}pre code{padding:0;background:none} +table{border-collapse:collapse;margin:12px 0}th,td{border:1px solid #d1d5db;padding:6px 10px}blockquote{margin:12px 0;padding:2px 14px;border-left:3px solid #d1d5db;color:#6b7280}img{max-width:100%} +.cu-top{font-size:12px;color:#6b7280;margin-bottom:18px;word-break:break-all}.cu-top a{color:inherit}</style></head> +<body><div class="cu-top">${esc(target)} · <a href="#" onclick="fetch('/api/local-file/open',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:${JSON.stringify(target)},mode:'reveal'})});return false">在访达中显示</a></div>${body}</body></html>`; +}
tools/import_to_claude_desktop.py导入桌面客户端的工具#!/usr/bin/env python3
"""把 ~/.claude/projects 里的 Claude Code 对话挂到 Claude 桌面客户端(Code 标签页)左侧列表。
CloudCLI 和终端里的 claude 都把对话写在 ~/.claude/projects/<编码后的目录>/<id>.jsonl,
桌面客户端左侧列表的每一项则是
~/Library/Application Support/Claude/claude-code-sessions/<账号>/<组织>/local_<uuid>.json,
里面的 cliSessionId 指向那个 jsonl。本脚本只新建这些小 JSON,不改任何对话记录。
用法:
python3 import_to_claude_desktop.py # 试运行:只统计、预览标题
python3 import_to_claude_desktop.py --apply # 真正写入,之后 ⌘Q 退出客户端再打开
撤销: 删掉本脚本写入的 local_*.json(文件里带 "importedBy": "cloudcli-kit")。
"""
import glob
import json
import os
import re
import sys
import uuid
from datetime import datetime
APPLY = "--apply" in sys.argv
PROJECTS = os.path.expanduser("~/.claude/projects")
SESSIONS_ROOT = os.path.expanduser("~/Library/Application Support/Claude/claude-code-sessions")
def find_meta_dir():
# <账号>/<组织> 目录里放着现有会话的 local_*.json;取最近用过的那个
dirs = [d for d in glob.glob(os.path.join(SESSIONS_ROOT, "*", "*")) if os.path.isdir(d)]
if not dirs:
sys.exit("找不到桌面客户端的会话目录:先在 Claude 桌面客户端的 Code 标签页里随便开一个会话")
return max(dirs, key=os.path.getmtime)
def encode(path):
return re.sub(r"[^A-Za-z0-9]", "-", path)
def to_ms(ts):
return int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 1000)
def short_title(text):
text = re.sub(r"<command-name>(.*?)</command-name>", r"\1 ", text)
text = re.sub(r"<[^>]+>[^<]*</[^>]+>", "", text)
return re.sub(r"\s+", " ", text).strip()[:50]
def scan(path):
info = dict(ts=[], cwds=[], model=None, custom=None, ai=None, first=None, mode=None, entry=set())
for line in open(path, errors="ignore"):
try:
d = json.loads(line)
except ValueError:
continue
kind = d.get("type")
if d.get("timestamp"):
info["ts"].append(d["timestamp"])
if d.get("cwd") and d["cwd"] not in info["cwds"]:
info["cwds"].append(d["cwd"])
if d.get("entrypoint"):
info["entry"].add(d["entrypoint"])
if kind == "custom-title":
info["custom"] = d.get("customTitle")
elif kind == "ai-title":
info["ai"] = d.get("aiTitle")
elif kind == "permission-mode":
info["mode"] = d.get("permissionMode")
msg = d.get("message") or {}
if kind == "assistant" and msg.get("model") and not msg["model"].startswith("<"):
info["model"] = msg["model"]
if info["first"] is None and kind == "user" and not d.get("isMeta"):
content = msg.get("content")
if isinstance(content, list):
content = " ".join(x.get("text", "") for x in content if isinstance(x, dict) and x.get("type") == "text")
if content and content.strip():
info["first"] = content
return info
def main():
meta_dir = find_meta_dir()
linked = set()
for m in glob.glob(os.path.join(meta_dir, "local_*.json")):
try:
linked.add(json.load(open(m)).get("cliSessionId"))
except ValueError:
pass
new, skipped = [], {}
for proj in sorted(os.listdir(PROJECTS)):
for path in glob.glob(os.path.join(PROJECTS, proj, "*.jsonl")):
cli_id = os.path.basename(path)[:-6]
if cli_id in linked:
continue
s = scan(path)
reason = None
if not s["first"] or not s["ts"]:
reason = "没有用户消息"
elif s["entry"] and s["entry"] <= {"sdk-cli"}:
reason = "脚本里 claude -p 的一次性调用"
cwd = next((c for c in s["cwds"] if encode(c) == proj), s["cwds"][0] if s["cwds"] else None)
if not reason and not (cwd and os.path.isdir(cwd)):
reason = "工作目录已不存在"
if reason:
skipped[reason] = skipped.get(reason, 0) + 1
continue
new.append(dict(cli=cli_id, cwd=cwd, created=to_ms(min(s["ts"])), last=to_ms(max(s["ts"])),
model=s["model"], mode=s["mode"] or "default",
title=s["custom"] or s["ai"] or short_title(s["first"])))
print(f"客户端会话目录: {meta_dir}")
print(f"已在列表: {len(linked)} 待导入: {len(new)} 跳过: {skipped or 0}")
for o in sorted(new, key=lambda o: -o["last"])[:20]:
print(" ", datetime.fromtimestamp(o["last"] / 1000).strftime("%m-%d %H:%M"), o["title"])
if not APPLY:
print("\n这是试运行。确认无误后加 --apply 写入,然后 ⌘Q 退出 Claude 客户端再打开。")
return
for o in new:
sid = "local_" + str(uuid.uuid4())
meta = {
"sessionId": sid, "cliSessionId": o["cli"], "cwd": o["cwd"], "originCwd": o["cwd"],
"createdAt": o["created"], "lastActivityAt": o["last"], "lastFocusedAt": o["last"],
"isArchived": False, "title": o["title"], "titleSource": "user",
"permissionMode": o["mode"], "importedBy": "cloudcli-kit",
}
if o["model"]:
meta["model"] = o["model"]
with open(os.path.join(meta_dir, sid + ".json"), "w") as f:
json.dump(meta, f, ensure_ascii=False)
print(f"\n已写入 {len(new)} 条。⌘Q 完全退出 Claude 客户端再打开即可看到。")
if __name__ == "__main__":
main()