✨ feat(vscode): 设置面板可配置 Claude profiles 目录并修正探测顺序
This commit is contained in:
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user