feat(vscode): 设置面板可配置 Claude profiles 目录并修正探测顺序

This commit is contained in:
2026-07-23 21:52:53 +08:00
parent a3e557f9ee
commit 7c67d220db
13 changed files with 335 additions and 35 deletions
+118
View File
@@ -0,0 +1,118 @@
import { accessSync, constants, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
getProfilesDir,
resolveProfilePath,
setProfilesDirOverride,
} from './profiles'
const originalEnv = process.env.SUPERPOWERS_PROFILES_DIR
afterEach(() => {
setProfilesDirOverride(undefined)
if (originalEnv === undefined)
delete process.env.SUPERPOWERS_PROFILES_DIR
else
process.env.SUPERPOWERS_PROFILES_DIR = originalEnv
})
describe('getProfilesDir', () => {
it('env SUPERPOWERS_PROFILES_DIR 优先', () => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'profiles-env-'))
try {
process.env.SUPERPOWERS_PROFILES_DIR = dir
setProfilesDirOverride('~/ignored')
expect(getProfilesDir()).toBe(path.resolve(dir))
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('settings override 支持 ~ 并优先于 auto', () => {
delete process.env.SUPERPOWERS_PROFILES_DIR
const dir = mkdtempSync(path.join(os.tmpdir(), 'profiles-override-'))
try {
const tildePath = dir.replace(os.homedir(), '~')
setProfilesDirOverride(tildePath)
expect(getProfilesDir()).toBe(path.resolve(dir))
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('空 override 时优先 cruldra-profile 真实源(若可读)', () => {
delete process.env.SUPERPOWERS_PROFILES_DIR
setProfilesDirOverride(undefined)
const preferred = path.join(os.homedir(), 'Sources', 'cruldra-profile', 'claude-config', 'profiles')
try {
accessSync(preferred, constants.R_OK)
}
catch {
// 本机无该目录则跳过(CI / 其它机器)
return
}
expect(getProfilesDir()).toBe(preferred)
})
})
describe('resolveProfilePath', () => {
it('空值回退默认 profile 路径', () => {
delete process.env.SUPERPOWERS_PROFILES_DIR
const dir = mkdtempSync(path.join(os.tmpdir(), 'profiles-default-'))
try {
writeFileSync(path.join(dir, 'offical.json'), '{}')
setProfilesDirOverride(dir)
expect(resolveProfilePath(undefined)).toBe(path.join(dir, 'offical.json'))
expect(resolveProfilePath('')).toBe(path.join(dir, 'offical.json'))
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('可读绝对路径原样返回', () => {
delete process.env.SUPERPOWERS_PROFILES_DIR
const dir = mkdtempSync(path.join(os.tmpdir(), 'profiles-readable-'))
try {
const file = path.join(dir, 'grok-4.5.json')
writeFileSync(file, '{}')
setProfilesDirOverride(dir)
expect(resolveProfilePath(file)).toBe(file)
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('跨机绝对路径按 basename remap 到本机 profiles 目录', () => {
delete process.env.SUPERPOWERS_PROFILES_DIR
const dir = mkdtempSync(path.join(os.tmpdir(), 'profiles-remap-'))
try {
writeFileSync(path.join(dir, 'grok-4.5.json'), '{}')
setProfilesDirOverride(dir)
const foreign = '/Users/cruldra/.claude/profile-settings/grok-4.5.json'
expect(resolveProfilePath(foreign)).toBe(path.join(dir, 'grok-4.5.json'))
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('无法 remap 时返回原路径', () => {
delete process.env.SUPERPOWERS_PROFILES_DIR
const dir = mkdtempSync(path.join(os.tmpdir(), 'profiles-miss-'))
try {
mkdirSync(dir, { recursive: true })
setProfilesDirOverride(dir)
const foreign = '/Users/cruldra/.claude/profile-settings/missing-model.json'
expect(resolveProfilePath(foreign)).toBe(foreign)
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+129 -13
View File
@@ -1,17 +1,25 @@
/**
* Lists Claude settings profiles from a hardcoded directory. Each `.json`
* file there is treated as a profile whose `name` is its basename without
* the extension and whose `path` is the absolute file path, suitable for
* passing to `claude --settings <path>`.
* Lists Claude settings profiles from a resolved directory.
* Each `.json` file is a profile: `name` = basename without extension,
* `path` = absolute path for `claude --settings <path>`.
*
* Failures (directory missing, unreadable, etc.) are swallowed — the
* caller treats an empty list as "no profile selector".
* Resolution order for the profiles directory:
* 1. `$SUPERPOWERS_PROFILES_DIR` (env override)
* 2. settings `profilesDirectory` via setProfilesDirOverride (supports ~)
* 3. auto candidates (first readable):
* ~/Sources/cruldra-profile/claude-config/profiles
* ~/.claude/profile-settings
* ~/.claude/profiles
* 4. fallback to the cruldra-profile path for error messages
*
* Missing/unreadable dirs yield an empty list — never throws.
*/
import { accessSync, constants } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import * as path from 'node:path'
const PROFILES_DIR = '/home/cruldra/Sources/cruldra-profile/claude-config/profiles'
import { expandTilde } from '../git/worktree'
export interface ClaudeProfile {
/** basename without .json */
@@ -20,20 +28,100 @@ export interface ClaudeProfile {
path: string
}
/** Settings-panel override; empty/undefined means auto-detect. */
let profilesDirOverride: string | undefined
/**
* List Claude settings profiles from the hardcoded directory. Returns an
* empty array if the directory is missing or unreadable; does not throw.
* Sorted alphabetically by name.
* Apply settings `profilesDirectory` (or clear with empty/undefined).
* Call on activate, settings open, and after settings save.
*/
export function setProfilesDirOverride(dir: string | undefined): void {
const t = dir?.trim()
profilesDirOverride = t ? t : undefined
}
function isReadableDir(dir: string): boolean {
try {
accessSync(dir, constants.R_OK)
return true
}
catch {
return false
}
}
function isReadableFile(file: string): boolean {
try {
accessSync(file, constants.R_OK)
return true
}
catch {
return false
}
}
/**
* Resolve the Claude profiles directory without hardcoding a user home path.
* Prefers the first existing candidate; falls back to the conventional path
* under `$HOME` so error messages stay meaningful when nothing exists yet.
*/
export function getProfilesDir(): string {
// ① env 显式覆盖优先
const envOverride = process.env.SUPERPOWERS_PROFILES_DIR?.trim()
if (envOverride)
return path.resolve(envOverride)
// ② 设置面板 override(支持 ~
if (profilesDirOverride)
return path.resolve(expandTilde(profilesDirOverride))
// ③ auto:真实 profile 源优先,再兼容常见链接/目录
const home = homedir()
const candidates = [
path.join(home, 'Sources', 'cruldra-profile', 'claude-config', 'profiles'),
path.join(home, '.claude', 'profile-settings'),
path.join(home, '.claude', 'profiles'),
]
for (const dir of candidates) {
if (isReadableDir(dir))
return dir
}
return candidates[0]!
}
/**
* Remap a stored profile path (often an absolute path from another machine)
* onto the current profiles directory by basename when the original is missing.
*/
export function resolveProfilePath(stored: string | undefined): string {
const raw = stored?.trim()
if (!raw)
return getDefaultProfilePath()
if (isReadableFile(raw))
return raw
const remapped = path.join(getProfilesDir(), path.basename(raw))
if (isReadableFile(remapped))
return remapped
return raw
}
/**
* List Claude settings profiles. Empty array if the directory is missing
* or unreadable. Sorted alphabetically by name.
*/
export async function listClaudeProfiles(): Promise<ClaudeProfile[]> {
const dir = getProfilesDir()
try {
const entries = await readdir(PROFILES_DIR)
const entries = await readdir(dir)
const profiles: ClaudeProfile[] = []
for (const entry of entries) {
if (!entry.endsWith('.json'))
continue
const name = entry.slice(0, -'.json'.length)
profiles.push({ name, path: path.join(PROFILES_DIR, entry) })
profiles.push({ name, path: path.join(dir, entry) })
}
profiles.sort((a, b) => a.name.localeCompare(b.name))
return profiles
@@ -42,3 +130,31 @@ export async function listClaudeProfiles(): Promise<ClaudeProfile[]> {
return []
}
}
/**
* Default profile for new sessions: prefer `offical.json` (legacy spelling),
* then `official.json`. Path is always under getProfilesDir().
*/
export function getDefaultProfilePath(): string {
const dir = getProfilesDir()
for (const name of ['offical.json', 'official.json']) {
const p = path.join(dir, name)
if (isReadableFile(p))
return p
}
return path.join(dir, 'offical.json')
}
/**
* Profile for PR diff summaries / background content generation.
* Prefer `deepseek-v4-pro`, then `deepseek.json`.
*/
export function getPrDiffSummaryProfilePath(): string {
const dir = getProfilesDir()
for (const name of ['deepseek-v4-pro.json', 'deepseek.json']) {
const p = path.join(dir, name)
if (isReadableFile(p))
return p
}
return path.join(dir, 'deepseek-v4-pro.json')
}