342 lines
15 KiB
TypeScript
342 lines
15 KiB
TypeScript
/**
|
|
* 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 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。`
|
|
|
|
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 第一行写 <!-- spx:review=1 -->。`
|
|
|
|
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 `<workspaceRoot>/.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 `<workspaceRoot>/.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 `<workspaceRoot>/.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
|
|
* `<workspaceRoot>/.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<Partial<Settings>>(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<Settings>): Promise<void> {
|
|
const current = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
|
|
const merged: Partial<Settings> = { ...current, ...next }
|
|
await ctx.globalState.update(SETTINGS_KEY, merged)
|
|
}
|