/** * Non-secret extension settings persisted as `settings.json` under * `context.globalStorageUri`. That directory survives VSIX 更新;`globalState` * 在部分 VS Code profile 下只活在内存里,一重载就丢。 * * Token stays in `context.secrets` (see `src/auth/secrets.ts`). * 读:文件 → 旧 globalState(一次性迁移)。写:只写文件。 * * Empty-string values for prompts are treated as "use the default" so the * form can save user input verbatim and still fall back when the user clears * a field. */ import type { ExtensionContext } from 'vscode' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import path from 'node:path' import { seedCommitProfilePath } from '../cc/commitProfile' import { listClaudeProfiles } from '../cc/profiles' import { normalizeReleasePrefix } from '../git/release' /** * Fallback prompt strings used when the on-disk markdown can't be read. * The full editable versions live in `prompts/*.md` at the extension root; * users can tweak them without rebuilding. These short fallbacks just keep * the extension usable if the file is missing/corrupt. */ const FALLBACK_BRAINSTORM_PROMPT = `我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 。创建完工单立即停下汇报,不要擅自实施。` const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumber} 号工单。达成共识后用 ecc:spec 写 .claude/specs/.md,我确认后用 ecc:plan 出 .claude/plans/.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。` const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。` const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。验收目标以工单正文为准,不要再问验收哪个。` const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{prNumber} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 。` const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。` type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review' | 'test' | 'prototype' /** * Read a default prompt template from `${extensionPath}/prompts/${name}.md`. * * This is called every time `getSettings` runs, so edits to the markdown * file take effect on the next cc/codex tab launch without reloading the * window. If the file is missing or unreadable we fall back to a short * inline string so the extension stays functional. */ export function readDefaultPrompt(extensionPath: string, name: PromptName): string { const filePath = path.join(extensionPath, 'prompts', `${name}.md`) try { return readFileSync(filePath, 'utf-8') } catch (err) { console.warn(`[spx] 读 ${filePath} 失败,使用内联 fallback:`, err) switch (name) { case 'brainstorm': return FALLBACK_BRAINSTORM_PROMPT case 'brainstorm-continue': return FALLBACK_BRAINSTORM_CONTINUE_PROMPT case 'implement-plan': return FALLBACK_IMPLEMENT_PLAN_PROMPT case 'review': return FALLBACK_REVIEW_PROMPT case 'test': return FALLBACK_TEST_PROMPT case 'prototype': return FALLBACK_PROTOTYPE_PROMPT } } } export const DEFAULT_WEBHOOK_PORT = 17421 export interface Settings { /** Local HTTP port for receiving gitea webhook callbacks. */ webhookPort: number /** * Gitea webhook 的 secret,用于校验 `X-Gitea-Signature`(HMAC-SHA256)。 * webhook 经 frp 暴露公网,不校验等于任何人都能伪造事件触发 codex。 * 空 = 不校验(未在 Gitea 侧配置 secret 的过渡状态)。 */ webhookSecret: string /** * Prompt template for the brainstorming flow (issue creation + ongoing * session conventions for spec/plan body annotations). `{userRequest}` * and `{nonce}` placeholders. */ brainstormPrompt: string /** * Prompt template for continuing brainstorm on an already-existing issue * (when the user later wants to discuss / write spec/plan in the panel). * `{issueNumber}` placeholder. */ brainstormContinuePrompt: string /** Prompt template for the implement-plan flow. `{planFile}` placeholder. */ implementPlanPrompt: string /** Whether to automatically run `codex exec review` when a PR opens. */ autoReview: boolean /** Prompt template for the auto-review flow. `{prNumber}` placeholder. */ reviewPrompt: string /** 测试会话首个提示词模板;占位符 {issueNumber} / {prRef}。 */ testPrompt: string /** 原型会话首个提示词模板;占位符 {issueNumber}。 */ prototypePrompt: string /** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */ devBranch: string /** * 发布分支名前缀(如 `release/`),远端该前缀下字典序最大的分支即当期发布点。 * 读出时已归一化:trim、空回退默认、末尾补 `/`。 */ releaseBranchPrefix: string /** 正式服部署的 Jenkins 任务名;空串 = 不提供「部署到正式服」。 */ jenkinsJob: string /** Jenkins 任务的 `REMOTE_HOST` 参数取值,空回退 `正式服`。 */ jenkinsProdHost: string /** * Directory template for the worktree created when implementing a plan. * Placeholders: `$project_root` (workspace abs path), `$project_name` (its * basename), `$feature_name` (the spec/plan slug). A leading `~` expands to * the user's home dir. Empty string means "use the default * `~/Sources/worktree/$project_name/$feature_name`". */ worktreeDirectory: string /** * Which editor opens a worktree when the user clicks it in the detail * panel. `'pycharm'` shells out to the JetBrains `pycharm` launcher; * `'vscode'` (default) opens a new VS Code window. */ worktreeOpenWith: 'vscode' | 'pycharm' /** * Command used to launch PyCharm when `worktreeOpenWith === 'pycharm'`. * A bare name relies on PATH, which a GUI-launched VS Code often lacks, so * an absolute path (or a `~`-prefixed one) is the reliable choice. Empty * means "use the default `pycharm`". */ pycharmCommand: string /** * Path to a user-provided shell script that runs *after* the extension * creates a worktree via `git worktree add`. Empty string means use the * default `/.spx/worktree-post-create.sh`. Absent file * (default path or user-specified) is treated as a no-op, not an error. * * Hook receives env: `WORKTREE_PATH`, `WORKSPACE_ROOT`, `BRANCH`, * `ISSUE_NUMBER`, `MAIN_BRANCH`. */ worktreePostCreateScript: string /** * Path to a user-provided shell script that runs *before* the extension * removes a worktree via `git worktree remove`. Empty string means use * the default `/.spx/worktree-pre-remove.sh`. Absent file * is treated as a no-op. * * Same env vars as the post-create hook. */ worktreePreRemoveScript: string /** * Path to a user-provided shell script that runs *before* the extension * spawns the implement cc terminal tab. Empty string means use the * default `/.spx/impl-tab-pre-create.sh`. Absent file is * treated as a no-op. Tab-reuse fast path (already-open impl terminal) * does NOT trigger this hook. * * Same env vars as the worktree hooks. */ implTabPreCreateScript: string /** * Path to a user-provided shell script that runs *after* the implement * cc terminal tab is closed (detected via `onDidCloseTerminal`). Empty * string means use the default * `/.spx/impl-tab-post-close.sh`. Absent file is treated * as a no-op. Typically used to tear down an IDE launched by the * pre-create hook. * * Same env vars as the worktree hooks. */ implTabPostCloseScript: string /** * 新开会话终端是否分到右侧一栏。关掉则在当前编辑组加标签,适合小屏。 */ splitNewTerminals: boolean /** * YouTrack instance base URL (e.g. `https://ziwuxian.youtrack.cloud`). Empty * string means "YouTrack source disabled" — the loader returns no issues. */ youtrackBaseUrl: string /** * Short name of the YouTrack project to mirror into the board (e.g. `LXF`). * Empty string means "disabled". The token lives in SecretStorage keyed by * the base URL's host. */ youtrackProjectShortName: string /** * Optional YouTrack command applied when a youtrack card is dropped in the * 完成 column (e.g. `State Fixed`). Empty string means "auto-detect" — the * extension finds the project's first `isResolved` state value and uses that. */ youtrackCloseCommand: string /** * 头脑风暴会话的默认 Claude profile 绝对路径。工单详情面板里的 * 「头脑风暴配置文件」为空时用它;它也为空才落到 DEFAULT_PROFILE_PATH。 */ defaultBrainstormProfilePath: string /** 实施会话的默认 Claude profile;工单级「实施配置文件」为空时用它。 */ defaultImplementProfilePath: string /** 测试会话的默认 Claude profile;工单级「测试配置文件」为空时用它。 */ defaultTestProfilePath: string /** 原型会话的默认 Claude profile;工单级「原型配置文件」为空时用它。 */ defaultPrototypeProfilePath: string /** * Claude settings profile path for the conflict-resolution cc session * (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH. */ conflictResolutionProfilePath: string /** * 工具栏「提交代码」使用的 Claude profile 绝对路径。 * 空串 = 未配置 → 提交严格失败。升级时 key 缺失会 seed deepseek-v4-pro(若存在)。 */ commitProfilePath: string /** * 「改动」tab 单文件说明 / 工单 PR 变更摘要使用的 Claude profile 绝对路径。 * 空串 = 未配置 → 生成失败。升级时 key 缺失会 seed deepseek-v4-pro(若存在)。 */ prDiffSummaryProfilePath: string /** * codex 审查会话使用的模型(`-c model=`)。空串 = 不传,用 codex * `config.toml` 默认。 */ codexModel: string /** * codex 审查会话的思考级别(`-c model_reasoning_effort=`)。空串 = 不传, * 用 codex 默认。合法值 minimal/low/medium/high/xhigh。 */ codexReasoningEffort: string /** * Claude `--settings` profile 所在目录。空串 = 自动探测。 * 支持前导 `~`。保存后 listClaudeProfiles / getDefaultProfilePath 都从这里读。 */ profilesDirectory: string } export const SETTINGS_KEY = 'superpowers.settings' const SETTINGS_FILE = 'settings.json' export function settingsFilePath(ctx: ExtensionContext): string { return path.join(ctx.globalStorageUri.fsPath, SETTINGS_FILE) } function parseStoredBlob(raw: unknown): Partial | null { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null return raw as Partial } /** 文件优先;没有文件时读旧 globalState,供迁移。 */ export function readStoredSettings(ctx: ExtensionContext): Partial { try { const parsed: unknown = JSON.parse(readFileSync(settingsFilePath(ctx), 'utf8')) const blob = parseStoredBlob(parsed) if (blob) return blob } catch { // 文件不存在或坏掉:回退 memento。 } return parseStoredBlob(ctx.globalState.get(SETTINGS_KEY)) ?? {} } function writeStoredSettings(ctx: ExtensionContext, blob: Partial): void { const dir = ctx.globalStorageUri.fsPath mkdirSync(dir, { recursive: true }) const file = settingsFilePath(ctx) const tmp = `${file}.tmp` writeFileSync(tmp, `${JSON.stringify(blob, null, 2)}\n`) renameSync(tmp, file) } function defaults(ctx: ExtensionContext): Settings { return { webhookPort: DEFAULT_WEBHOOK_PORT, webhookSecret: '', brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'), brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'), implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'), autoReview: true, reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'), testPrompt: readDefaultPrompt(ctx.extensionPath, 'test'), prototypePrompt: readDefaultPrompt(ctx.extensionPath, 'prototype'), devBranch: 'main', releaseBranchPrefix: 'release/', jenkinsJob: '', jenkinsProdHost: '正式服', worktreeDirectory: '~/Sources/worktree/$project_name/$feature_name', worktreeOpenWith: 'vscode', pycharmCommand: 'pycharm', worktreePostCreateScript: '', worktreePreRemoveScript: '', implTabPreCreateScript: '', implTabPostCloseScript: '', splitNewTerminals: true, youtrackBaseUrl: '', youtrackProjectShortName: '', youtrackCloseCommand: '', defaultBrainstormProfilePath: '', defaultImplementProfilePath: '', defaultTestProfilePath: '', defaultPrototypeProfilePath: '', conflictResolutionProfilePath: '', commitProfilePath: '', prDiffSummaryProfilePath: '', codexModel: '', codexReasoningEffort: '', profilesDirectory: '', } } export function getSettings(ctx: ExtensionContext): Settings { const stored = readStoredSettings(ctx) const base = defaults(ctx) const webhookPort = typeof stored.webhookPort === 'number' && Number.isInteger(stored.webhookPort) && stored.webhookPort >= 1 && stored.webhookPort <= 65535 ? stored.webhookPort : base.webhookPort // webhook secret:'' 有意义(= 不校验),只认字符串类型。 const webhookSecret = typeof stored.webhookSecret === 'string' ? stored.webhookSecret : base.webhookSecret const brainstormPrompt = typeof stored.brainstormPrompt === 'string' && stored.brainstormPrompt.length > 0 ? stored.brainstormPrompt : base.brainstormPrompt const brainstormContinuePrompt = typeof stored.brainstormContinuePrompt === 'string' && stored.brainstormContinuePrompt.length > 0 ? stored.brainstormContinuePrompt : base.brainstormContinuePrompt const implementPlanPrompt = typeof stored.implementPlanPrompt === 'string' && stored.implementPlanPrompt.length > 0 ? stored.implementPlanPrompt : base.implementPlanPrompt const autoReview = typeof stored.autoReview === 'boolean' ? stored.autoReview : base.autoReview const reviewPrompt = typeof stored.reviewPrompt === 'string' && stored.reviewPrompt.length > 0 ? stored.reviewPrompt : base.reviewPrompt const testPrompt = typeof stored.testPrompt === 'string' && stored.testPrompt.length > 0 ? stored.testPrompt : base.testPrompt const prototypePrompt = typeof stored.prototypePrompt === 'string' && stored.prototypePrompt.length > 0 ? stored.prototypePrompt : base.prototypePrompt // devBranch always has a usable value — fall back to 'main' if missing/blank // so we never end up trying to fetch the empty string. const devBranch = typeof stored.devBranch === 'string' && stored.devBranch.length > 0 ? stored.devBranch : base.devBranch // 发布前缀读出时归一化(空 → 默认、补尾 `/`),下游拼分支名不用再防御。 const releaseBranchPrefix = normalizeReleasePrefix( typeof stored.releaseBranchPrefix === 'string' ? stored.releaseBranchPrefix : base.releaseBranchPrefix, ) // jenkinsJob:'' 有意义(= 不提供部署动作),只认字符串类型。 const jenkinsJob = typeof stored.jenkinsJob === 'string' ? stored.jenkinsJob.trim() : base.jenkinsJob const jenkinsProdHost = typeof stored.jenkinsProdHost === 'string' && stored.jenkinsProdHost.trim().length > 0 ? stored.jenkinsProdHost.trim() : base.jenkinsProdHost // worktree dir template: empty/blank means "use the default template", so // fall back to base (unlike the hook paths below, where '' is meaningful). const worktreeDirectory = typeof stored.worktreeDirectory === 'string' && stored.worktreeDirectory.length > 0 ? stored.worktreeDirectory : base.worktreeDirectory // worktreeOpenWith is an enum — only the two known values are valid; anything // else (legacy installs, garbage) collapses to the default. const worktreeOpenWith = stored.worktreeOpenWith === 'pycharm' || stored.worktreeOpenWith === 'vscode' ? stored.worktreeOpenWith : base.worktreeOpenWith // pycharm launcher command: blank means "use the default `pycharm`", so fall // back to base rather than preserving an empty string. const pycharmCommand = typeof stored.pycharmCommand === 'string' && stored.pycharmCommand.length > 0 ? stored.pycharmCommand : base.pycharmCommand // worktree hook script paths: '' is meaningful (= "use default // .spx/worktree-*.sh"), so don't coerce. Non-string legacy values fall // back to ''. The hook runner resolves '' to the default at execution // time and silently skips when the file doesn't exist. const worktreePostCreateScript = typeof stored.worktreePostCreateScript === 'string' ? stored.worktreePostCreateScript : base.worktreePostCreateScript const worktreePreRemoveScript = typeof stored.worktreePreRemoveScript === 'string' ? stored.worktreePreRemoveScript : base.worktreePreRemoveScript // impl-tab hook script paths: same '' = default semantics as worktree hooks. const implTabPreCreateScript = typeof stored.implTabPreCreateScript === 'string' ? stored.implTabPreCreateScript : base.implTabPreCreateScript const implTabPostCloseScript = typeof stored.implTabPostCloseScript === 'string' ? stored.implTabPostCloseScript : base.implTabPostCloseScript const splitNewTerminals = typeof stored.splitNewTerminals === 'boolean' ? stored.splitNewTerminals : base.splitNewTerminals // YouTrack config: '' is meaningful (= "source disabled" / "auto-detect close // command"), so don't coerce. Non-string legacy values collapse to ''. const youtrackBaseUrl = typeof stored.youtrackBaseUrl === 'string' ? stored.youtrackBaseUrl : base.youtrackBaseUrl const youtrackProjectShortName = typeof stored.youtrackProjectShortName === 'string' ? stored.youtrackProjectShortName : base.youtrackProjectShortName const youtrackCloseCommand = typeof stored.youtrackCloseCommand === 'string' ? stored.youtrackCloseCommand : base.youtrackCloseCommand // 四个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),不强制回退。 const defaultBrainstormProfilePath = typeof stored.defaultBrainstormProfilePath === 'string' ? stored.defaultBrainstormProfilePath : base.defaultBrainstormProfilePath const defaultImplementProfilePath = typeof stored.defaultImplementProfilePath === 'string' ? stored.defaultImplementProfilePath : base.defaultImplementProfilePath const defaultTestProfilePath = typeof stored.defaultTestProfilePath === 'string' ? stored.defaultTestProfilePath : base.defaultTestProfilePath const defaultPrototypeProfilePath = typeof stored.defaultPrototypeProfilePath === 'string' ? stored.defaultPrototypeProfilePath : base.defaultPrototypeProfilePath // conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH at // launch time), so don't coerce. Non-string legacy values collapse to ''. const conflictResolutionProfilePath = typeof stored.conflictResolutionProfilePath === 'string' ? stored.conflictResolutionProfilePath : base.conflictResolutionProfilePath // 提交 profile:'' 有意义(= 未配置 → 提交失败)。缺 key 时先给 ''; // deepseek seed 在 getEffectiveCommitProfilePath 里做(需异步 list profiles)。 const commitProfilePath = typeof stored.commitProfilePath === 'string' ? stored.commitProfilePath : base.commitProfilePath const prDiffSummaryProfilePath = typeof stored.prDiffSummaryProfilePath === 'string' ? stored.prDiffSummaryProfilePath : base.prDiffSummaryProfilePath // codex 模型 / 思考级别:'' 有意义(= 用 codex 默认),不强制回退。 const codexModel = typeof stored.codexModel === 'string' ? stored.codexModel : base.codexModel const codexReasoningEffort = typeof stored.codexReasoningEffort === 'string' ? stored.codexReasoningEffort : base.codexReasoningEffort // profiles 目录:'' 有意义(= 自动探测),不强制回退。 const profilesDirectory = typeof stored.profilesDirectory === 'string' ? stored.profilesDirectory : base.profilesDirectory return { webhookPort, webhookSecret, brainstormPrompt, brainstormContinuePrompt, implementPlanPrompt, autoReview, reviewPrompt, testPrompt, prototypePrompt, devBranch, releaseBranchPrefix, jenkinsJob, jenkinsProdHost, worktreeDirectory, worktreeOpenWith, pycharmCommand, worktreePostCreateScript, worktreePreRemoveScript, implTabPreCreateScript, implTabPostCloseScript, splitNewTerminals, youtrackBaseUrl, youtrackProjectShortName, youtrackCloseCommand, defaultBrainstormProfilePath, defaultImplementProfilePath, defaultTestProfilePath, defaultPrototypeProfilePath, conflictResolutionProfilePath, commitProfilePath, prDiffSummaryProfilePath, codexModel, codexReasoningEffort, profilesDirectory, } } /** * 读取工具栏「提交代码」有效的 profile path。 * ① stored 已有 key(含用户清空的 '')→ 原样返回 * ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(启动时 persistSeededProfiles 会写盘) */ export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Promise { const stored = readStoredSettings(ctx) if (typeof stored.commitProfilePath === 'string') return stored.commitProfilePath const profiles = await listClaudeProfiles() return seedCommitProfilePath(undefined, profiles) } /** * 读取改动说明有效的 profile path。规则与提交代码相同:已有 key 原样返回,缺 key 才 seed。 */ export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext): Promise { const stored = readStoredSettings(ctx) if (typeof stored.prDiffSummaryProfilePath === 'string') return stored.prDiffSummaryProfilePath const profiles = await listClaudeProfiles() return seedCommitProfilePath(undefined, profiles) } export async function saveSettings(ctx: ExtensionContext, next: Partial): Promise { const merged: Partial = { ...readStoredSettings(ctx), ...next } writeStoredSettings(ctx, merged) } /** * 启动时:把旧 memento 迁到文件;commit / 改动说明 profile 缺 key 且能 seed 则写盘。 * 空串视为用户主动清空,不覆盖。 */ export async function persistSeededProfiles(ctx: ExtensionContext): Promise { const stored = readStoredSettings(ctx) const fileExists = existsSync(settingsFilePath(ctx)) const profiles = await listClaudeProfiles() const patch: Partial = {} if (typeof stored.commitProfilePath !== 'string') { const seeded = seedCommitProfilePath(undefined, profiles) if (seeded) patch.commitProfilePath = seeded } if (typeof stored.prDiffSummaryProfilePath !== 'string') { const seeded = seedCommitProfilePath(undefined, profiles) if (seeded) patch.prDiffSummaryProfilePath = seeded } const shouldMigrate = !fileExists && Object.keys(stored).length > 0 if (shouldMigrate || Object.keys(patch).length > 0) await saveSettings(ctx, patch) }