/** * Non-secret extension settings persisted in `globalState`. * * Token stays in `context.secrets` (see `src/auth/secrets.ts`); everything * else (webhook port, prompt templates) lives here under a single JSON blob * keyed by `SETTINGS_KEY`. * * 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 { readFileSync } from 'node:fs' import path from 'node:path' import type { ExtensionContext } from 'vscode' /** * 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 = `/goal 我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 。创建完工单立即停下汇报,不要擅自实施。` const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/superpowers:brainstorming 讨论下 {issueNumber} 号工单。注意:不要 git checkout / 不要写代码 / 不要建 PR,只讨论需求与 spec/plan,用 spx issue marker 更新 marker。` const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。` const FALLBACK_REVIEW_PROMPT = `/review 用 tea 拿到 #{prNumber} PR 审查。审查意见用 spx pr review-comment 发评论,body 第一行写 。` type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review' /** * 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) { // eslint-disable-next-line no-console 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 } } } export const DEFAULT_WEBHOOK_PORT = 17421 export interface Settings { /** Local HTTP port for receiving gitea webhook callbacks. */ webhookPort: number /** * 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 /** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */ devBranch: string /** * Branch the Gitea webhook → Jenkins job listens on. Empty string means * "same as `devBranch`", in which case the branch-sync button stays * disabled because there's nothing to fast-forward. */ autoBuildBranch: 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 /** * 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 settings profile path for the conflict-resolution cc session * (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH. */ conflictResolutionProfilePath: 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' function defaults(ctx: ExtensionContext): Settings { return { webhookPort: DEFAULT_WEBHOOK_PORT, brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'), brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'), implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'), autoReview: true, reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'), devBranch: 'main', autoBuildBranch: '', worktreeDirectory: '~/Sources/worktree/$project_name/$feature_name', worktreeOpenWith: 'vscode', pycharmCommand: 'pycharm', worktreePostCreateScript: '', worktreePreRemoveScript: '', implTabPreCreateScript: '', implTabPostCloseScript: '', youtrackBaseUrl: '', youtrackProjectShortName: '', youtrackCloseCommand: '', conflictResolutionProfilePath: '', codexModel: '', codexReasoningEffort: '', profilesDirectory: '', } } export function getSettings(ctx: ExtensionContext): Settings { const stored = ctx.globalState.get>(SETTINGS_KEY) ?? {} const base = defaults(ctx) const webhookPort = typeof stored.webhookPort === 'number' && Number.isInteger(stored.webhookPort) && stored.webhookPort >= 1 && stored.webhookPort <= 65535 ? stored.webhookPort : base.webhookPort 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 // 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 // autoBuildBranch is kept as-is — '' is meaningful ("follow devBranch, sync // disabled"), so we don't coerce to a default here. Anything non-string // (legacy installs that never wrote it) collapses to ''. const autoBuildBranch = typeof stored.autoBuildBranch === 'string' ? stored.autoBuildBranch : base.autoBuildBranch // 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 // 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 // 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 // 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, brainstormPrompt, brainstormContinuePrompt, implementPlanPrompt, autoReview, reviewPrompt, devBranch, autoBuildBranch, worktreeDirectory, worktreeOpenWith, pycharmCommand, worktreePostCreateScript, worktreePreRemoveScript, implTabPreCreateScript, implTabPostCloseScript, youtrackBaseUrl, youtrackProjectShortName, youtrackCloseCommand, conflictResolutionProfilePath, codexModel, codexReasoningEffort, profilesDirectory, } } export async function saveSettings(ctx: ExtensionContext, next: Partial): Promise { const current = ctx.globalState.get>(SETTINGS_KEY) ?? {} const merged: Partial = { ...current, ...next } await ctx.globalState.update(SETTINGS_KEY, merged) }