@dsh-plus/actual 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 「凭据」小节:三行密钥(服务器口令 / 会话令牌 / 端到端加密口令),
3
+ * 每行可写可清,右侧徽标显示「已配置 / 未配置」。
4
+ *
5
+ * 行内只做呈现与暂存:值一经保存即从输入框清空,不回显、不驻留 DOM;
6
+ * 引用被进程环境遮蔽(`writable:false`)时禁用编辑并说明原因——这正是
7
+ * 部署方把口令放进 systemd/shell 环境的场景,必须让用户看懂为什么改不了。
8
+ * @module @dsh-plus/actual/client/secrets-section
9
+ */
10
+ import { Button, TextField } from '@dsh-plus/shared/client'
11
+ import { type ReactElement, useState } from 'react'
12
+
13
+ import { REF_ENCRYPTION_PASSWORD, REF_PASSWORD, REF_SESSION_TOKEN } from '../refs.ts'
14
+ import type { DictKey, Translate } from './i18n.ts'
15
+ import {
16
+ type CredentialsRemoteFace,
17
+ type CredentialView,
18
+ useCredentials,
19
+ viewOf,
20
+ } from './secrets.ts'
21
+
22
+ /** 一行密钥的定义:引用名 + 文案键。 */
23
+ interface SecretRowSpec {
24
+ ref: string
25
+ labelKey: DictKey
26
+ hintKey: DictKey
27
+ }
28
+
29
+ /** 三行密钥的固定顺序(口令 → 令牌 → 加密口令)。 */
30
+ export const SECRET_ROWS: readonly SecretRowSpec[] = [
31
+ { ref: REF_PASSWORD, labelKey: 'secretPassword', hintKey: 'secretPasswordHint' },
32
+ { ref: REF_SESSION_TOKEN, labelKey: 'secretToken', hintKey: 'secretTokenHint' },
33
+ {
34
+ ref: REF_ENCRYPTION_PASSWORD,
35
+ labelKey: 'secretEncryption',
36
+ hintKey: 'secretEncryptionHint',
37
+ },
38
+ ]
39
+
40
+ interface SecretRowProps {
41
+ t: Translate
42
+ row: SecretRowSpec
43
+ view: CredentialView
44
+ busy: boolean
45
+ onSave(ref: string, value: string): Promise<boolean>
46
+ onClear(ref: string): Promise<boolean>
47
+ }
48
+
49
+ /** 单行密钥:输入框 + 保存/清除,保存成功后清空输入。 */
50
+ function SecretRow(props: SecretRowProps): ReactElement {
51
+ const { t, row, view, busy } = props
52
+ const [text, setText] = useState('')
53
+ const locked = busy || !view.writable
54
+
55
+ return (
56
+ <div className="act-secret">
57
+ <TextField
58
+ prefix="act"
59
+ id={`act-secret-${row.ref}`}
60
+ label={t(row.labelKey)}
61
+ hint={t(row.hintKey)}
62
+ password
63
+ value={text}
64
+ disabled={locked}
65
+ badge={{ text: t(view.configured ? 'secretSet' : 'secretUnset'), set: view.configured }}
66
+ onEdit={setText}
67
+ />
68
+ <div className="act-secretActions">
69
+ <Button
70
+ prefix="act"
71
+ variant="primary"
72
+ label={t('save')}
73
+ disabled={locked || text === ''}
74
+ onClick={() => {
75
+ void props.onSave(row.ref, text).then((ok) => {
76
+ if (ok) setText('')
77
+ })
78
+ }}
79
+ />
80
+ <Button
81
+ prefix="act"
82
+ variant="ghost"
83
+ label={t('secretClear')}
84
+ disabled={locked || !view.configured}
85
+ onClick={() => {
86
+ void props.onClear(row.ref)
87
+ }}
88
+ />
89
+ </div>
90
+ {view.writable ? null : <p className="act-warn">{t('secretShadowed')}</p>}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ /** 凭据小节(读写经官方 `remote.credentials`)。 */
96
+ export function SecretsSection(props: { t: Translate; face: CredentialsRemoteFace }): ReactElement {
97
+ const { t, face } = props
98
+ const { views, busy, error, save, clear } = useCredentials(face)
99
+ return (
100
+ <div className="act-section">
101
+ <div className="act-sectionHead">
102
+ <h3 className="act-sectionTitle">{t('secretsTitle')}</h3>
103
+ </div>
104
+ <p className="act-sectionDesc">{t('secretsBody')}</p>
105
+ <div className="act-sectionBody">
106
+ {SECRET_ROWS.map((row) => (
107
+ <SecretRow
108
+ key={row.ref}
109
+ t={t}
110
+ row={row}
111
+ view={viewOf(views, row.ref)}
112
+ busy={busy}
113
+ onSave={save}
114
+ onClear={clear}
115
+ />
116
+ ))}
117
+ </div>
118
+ {error === '' ? null : (
119
+ <p className="act-warn">
120
+ {t('secretFailed')}
121
+ {error}
122
+ </p>
123
+ )}
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 浏览器半的凭据面:官方 settings-controller 的 `remote.credentials` 命名空间
3
+ * (describe/set/unset),复用官方 credentials seam,不另开 HTTP 端点。
4
+ * **值只写不读**——describe 只回「已配置 / 来源 / 可写」,没有任何方法回传值。
5
+ *
6
+ * 凭据与 settings 草稿相互独立:它不进命名空间、不参与脏判定,保存即落
7
+ * `$DSH_HOME/.credentials.yaml` 并热生效(宿主每次工具调用重新 resolve),
8
+ * 因此改口令不需要保存卡片、也不需要 /reload 或重启。
9
+ * @module @dsh-plus/actual/client/secrets
10
+ */
11
+ import type { RemoteResult } from '@dsh-plus/shared/client'
12
+ import { useCallback, useEffect, useState } from 'react'
13
+
14
+ import { CREDENTIAL_REFS } from '../refs.ts'
15
+
16
+ /** 单个引用的安全视图:没有能承载值的字段。 */
17
+ export interface CredentialView {
18
+ configured: boolean
19
+ source?: string
20
+ writable: boolean
21
+ }
22
+
23
+ /** 卡片用到的 `remote.credentials` 方法面。 */
24
+ export interface CredentialsRemoteFace {
25
+ describe(refs: readonly string[]): Promise<RemoteResult<Record<string, CredentialView>>>
26
+ set(ref: string, value: string): Promise<RemoteResult<unknown>>
27
+ unset(ref: string): Promise<RemoteResult<unknown>>
28
+ /** 订阅引用变更(宿主提交写入或观察到凭据文件被外部编辑时触发)。 */
29
+ onChange(listener: (ref: string) => void): () => void
30
+ }
31
+
32
+ /** 未答(或应答缺项)时的视图:按「未配置、可写」呈现。 */
33
+ export const ABSENT_VIEW: CredentialView = { configured: false, writable: true }
34
+
35
+ /** 取某引用的视图;应答缺项回落为 {@link ABSENT_VIEW}。 */
36
+ export function viewOf(views: Record<string, CredentialView>, ref: string): CredentialView {
37
+ return views[ref] ?? ABSENT_VIEW
38
+ }
39
+
40
+ /** 应答信封的错误文案(成功为空串)。 */
41
+ export function failureOf(result: RemoteResult<unknown>): string {
42
+ return result.ok ? '' : (result.error.message ?? '')
43
+ }
44
+
45
+ /** 提取异常文本。 */
46
+ function messageOf(error: unknown): string {
47
+ return error instanceof Error ? error.message : String(error)
48
+ }
49
+
50
+ /** 凭据读写的卡片状态。 */
51
+ export interface CredentialsState {
52
+ views: Record<string, CredentialView>
53
+ /** 有写入在途(按钮禁用用)。 */
54
+ busy: boolean
55
+ /** 最近一次失败文案(空串 = 无)。 */
56
+ error: string
57
+ /** 写入成功返回 true(调用方据此决定是否清空输入框)。 */
58
+ save(ref: string, value: string): Promise<boolean>
59
+ clear(ref: string): Promise<boolean>
60
+ }
61
+
62
+ /**
63
+ * 订阅宿主凭据变更:只认本插件关心的引用,其余(别的插件写自己的密钥)
64
+ * 不触发重读。
65
+ */
66
+ function useCredentialChanges(face: CredentialsRemoteFace, read: () => void): void {
67
+ useEffect(
68
+ () =>
69
+ face.onChange((ref) => {
70
+ if ((CREDENTIAL_REFS as readonly string[]).includes(ref)) read()
71
+ }),
72
+ [face, read],
73
+ )
74
+ }
75
+
76
+ /**
77
+ * 凭据行状态:挂载即 describe 一次,写入后重新 describe;
78
+ * 外部改动经 `credentials/reference-updated` 触发同一路径刷新。
79
+ */
80
+ export function useCredentials(face: CredentialsRemoteFace): CredentialsState {
81
+ const [views, setViews] = useState<Record<string, CredentialView>>({})
82
+ const [busy, setBusy] = useState(false)
83
+ const [error, setError] = useState('')
84
+
85
+ const read = useCallback(async (): Promise<void> => {
86
+ const result = await face.describe([...CREDENTIAL_REFS])
87
+ if (!result.ok) {
88
+ setError(failureOf(result))
89
+ return
90
+ }
91
+ setError('')
92
+ setViews(result.value)
93
+ }, [face])
94
+
95
+ useEffect(() => {
96
+ void read()
97
+ }, [read])
98
+ useCredentialChanges(face, read)
99
+
100
+ const write = useCallback(
101
+ async (run: () => Promise<RemoteResult<unknown>>): Promise<boolean> => {
102
+ setBusy(true)
103
+ try {
104
+ const result = await run()
105
+ if (!result.ok) {
106
+ setError(failureOf(result))
107
+ return false
108
+ }
109
+ setError('')
110
+ await read()
111
+ return true
112
+ } catch (thrown: unknown) {
113
+ setError(messageOf(thrown))
114
+ return false
115
+ } finally {
116
+ setBusy(false)
117
+ }
118
+ },
119
+ [read],
120
+ )
121
+
122
+ const save = useCallback(
123
+ (ref: string, value: string): Promise<boolean> => write(() => face.set(ref, value)),
124
+ [face, write],
125
+ )
126
+ const clear = useCallback(
127
+ (ref: string): Promise<boolean> => write(() => face.unset(ref)),
128
+ [face, write],
129
+ )
130
+
131
+ return { views, busy, error, save, clear }
132
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('act'),
3
- * 本文件只补插件特有规则(说明段落与代码片段)。
3
+ * 本文件只补插件特有规则(说明段落与凭据行)。
4
4
  * 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载)。
5
5
  * @module @dsh-plus/actual/client/styles
6
6
  */
@@ -12,7 +12,8 @@ const extraCss = `
12
12
  .act-note{margin:14px 0 0;padding:10px 12px;border-radius:8px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2)}
13
13
  .act-noteTitle{margin:0 0 6px;font-size:13px;font-weight:600}
14
14
  .act-noteBody{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6}
15
- .act-snippet{display:block;margin:8px 0 0;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family-mono,monospace);font-size:11px;line-height:1.5;white-space:pre;overflow-x:auto}
15
+ .act-secret{border-top:1px solid var(--dsw-alias-border-l2)}
16
+ .act-secretActions{display:flex;gap:8px;padding:0 0 12px}
16
17
  `
17
18
 
18
19
  export const cardCssAll = cardCss('act', extraCss)
package/src/config.ts CHANGED
@@ -9,10 +9,12 @@
9
9
  * loader 以活动引用原位提交热更新——消费端必须 `unwrapVolatile` 现取即热
10
10
  * (见 service.ts 的 `current()`)。
11
11
  *
12
- * **密钥不进用户层**:`password` / `sessionToken` / `encryptionPassword` 标
13
- * `.role('secret')`,describe 视图剥除其值、写入用户层也会被遮蔽,因此配置卡片
14
- * 不提供编辑器——它们只允许 profile 行级注入(`!!js process.env.…`)。
15
- * 见《插件存储规范》:密钥走环境变量/官方凭据,不落 settings。
12
+ * **密钥不落 settings**:`password` / `sessionToken` / `encryptionPassword` 标
13
+ * `.role('secret')`,describe 视图剥除其值、写入用户层也会被遮蔽,因此不提供
14
+ * settings 编辑器。它们的落点有二:① 配置卡片的「凭据」小节(走官方
15
+ * credentials seam,写 `$DSH_HOME/.credentials.yaml`);② profile 行级注入
16
+ * (`!!js process.env.…`)。行级声明了任一种认证即整组生效,否则回落到 seam。
17
+ * 见《插件存储规范》与 credentials.ts。
16
18
  * @module @dsh-plus/actual/config
17
19
  */
18
20
 
@@ -61,22 +63,20 @@ export const Config = z.object({
61
63
  .string()
62
64
  .role('secret')
63
65
  .description(
64
- '服务器口令(空 = 用 ACTUAL_PASSWORD / ACTUAL_PASSWORD_FILE;与 sessionToken 二选一)——密钥不进用户层,仅 profile 行级注入',
66
+ '服务器口令(与 sessionToken 二选一)——不落 settings:卡片「凭据」小节写凭据文件,或在此行级注入',
65
67
  )
66
68
  .default('')
67
69
  .volatile(),
68
70
  sessionToken: z
69
71
  .string()
70
72
  .role('secret')
71
- .description('会话令牌(优先于 password;空 = 用 ACTUAL_SESSION_TOKEN)——仅 profile 行级注入')
73
+ .description('会话令牌(优先于 password)——同上,卡片的「凭据」小节或行级注入')
72
74
  .default('')
73
75
  .volatile(),
74
76
  encryptionPassword: z
75
77
  .string()
76
78
  .role('secret')
77
- .description(
78
- '端到端加密口令(仅加密预算需要;空 = 用 ACTUAL_ENCRYPTION_PASSWORD)——仅 profile 行级注入',
79
- )
79
+ .description('端到端加密口令(仅加密预算需要)——同上,卡片的「凭据」小节或行级注入')
80
80
  .default('')
81
81
  .volatile(),
82
82
  dataDir: z
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 凭据解析:Actual 的三个密钥走官方 credentials seam
3
+ * (`$DSH_HOME/.credentials.yaml`,经 `ctx.credentials`),**每次操作即时
4
+ * resolve、不跨操作缓存**——在配置卡片里改口令后,下一次工具调用即生效。
5
+ *
6
+ * 引用名即官方 CLI 原生读取的环境变量名(见 refs.ts 与 `@dsh-plus/actual-mcp`
7
+ * 的 `SECRET_ENV`):`ACTUAL_` 前缀符合《插件存储规范》的 `<PLUGIN_ID>_` 大写
8
+ * 约定,同时让 seam 的继承环境层与 CLI 自己的环境读取指向同一处配置——
9
+ * 无论口令来自 `.credentials.yaml` 还是进程环境,两条路径都拿得到。
10
+ *
11
+ * 优先级:**行级 Config 声明了任一种认证即整组生效**。`role('secret')` 字段
12
+ * 是部署方在 profile 里的显式意图,不该被 seam 里的另一半悄悄改写;只有在
13
+ * 行级配置没声明认证时才去问 seam。端到端加密口令与认证正交,单独解析。
14
+ * @module @dsh-plus/actual/credentials
15
+ */
16
+
17
+ import type { CliSecrets } from '@dsh-plus/actual-mcp'
18
+
19
+ import { REF_ENCRYPTION_PASSWORD, REF_PASSWORD, REF_SESSION_TOKEN } from './refs.ts'
20
+
21
+ /**
22
+ * credentials seam 的最小面。
23
+ *
24
+ * 只声明用到的 `resolve`:值的写入发生在浏览器半(官方 `remote.credentials`
25
+ * 命名空间),宿主半不需要 set/unset。
26
+ *
27
+ * 引用名以 `unknown` 收、按普通字符串传:`CredentialRef` 是
28
+ * `string & { readonly [BRAND]: B }` 的**纯编译期**品牌(`BRAND` 只有类型声明,
29
+ * 无运行期取值,官方文档亦声明 `brandString` 不改动值),故传裸环境变量名与
30
+ * 经 `credentialRef()` 品牌化在运行期完全等价。这样本插件不必依赖
31
+ * `@deepseek-ai/dsh-credentials` 的包树,在未挂该 seam 的 profile 里也能 load。
32
+ */
33
+ export interface CredentialsFace {
34
+ /** 解析一个引用;未配置返回 undefined。 */
35
+ resolve(ref: unknown): Promise<{ value: string; source: string } | undefined>
36
+ }
37
+
38
+ /** 行级 Config 声明的密钥(空串 = 未声明)。 */
39
+ export type DeclaredSecrets = CliSecrets
40
+
41
+ /** 解析单个引用;seam 缺席或未配置一律视为空串。 */
42
+ async function resolveOne(seam: CredentialsFace | null, ref: string): Promise<string> {
43
+ if (seam === null) return ''
44
+ const found = await seam.resolve(ref)
45
+ return found === undefined ? '' : found.value
46
+ }
47
+
48
+ /**
49
+ * 解析本次调用使用的密钥三元组。
50
+ * @param seam - 凭据 seam;缺席(profile 未挂 dsh-credentials)时只用行级配置。
51
+ * @param declared - 行级 Config 声明的密钥。
52
+ * @returns 已定妥优先级的密钥三元组。
53
+ */
54
+ export async function resolveSecrets(
55
+ seam: CredentialsFace | null,
56
+ declared: DeclaredSecrets,
57
+ ): Promise<CliSecrets> {
58
+ // 行级 Config 是显式意图:声明了口令或令牌就整组作数,不再混入 seam 的另一半。
59
+ const authDeclared = declared.password !== '' || declared.sessionToken !== ''
60
+ const password =
61
+ declared.password !== ''
62
+ ? declared.password
63
+ : authDeclared
64
+ ? ''
65
+ : await resolveOne(seam, REF_PASSWORD)
66
+ const sessionToken =
67
+ declared.sessionToken !== ''
68
+ ? declared.sessionToken
69
+ : authDeclared
70
+ ? ''
71
+ : await resolveOne(seam, REF_SESSION_TOKEN)
72
+ const encryptionPassword =
73
+ declared.encryptionPassword !== ''
74
+ ? declared.encryptionPassword
75
+ : await resolveOne(seam, REF_ENCRYPTION_PASSWORD)
76
+ return { password, sessionToken, encryptionPassword }
77
+ }
package/src/index.ts CHANGED
@@ -40,10 +40,12 @@ export {
40
40
  actionOf,
41
41
  askDisplayReason,
42
42
  type ClassifySettings,
43
+ callToolResultOf,
43
44
  classifyAction,
44
45
  DEFAULT_ALWAYS_ASK,
45
46
  DEFAULT_READ_ACTIONS,
46
47
  DEFAULT_READ_TOOLS,
48
+ formatToolValue,
47
49
  toSettings,
48
50
  } from '@dsh-plus/actual-mcp'
49
51
  export { ACTUAL_PRESET_ID, actualPresetDefinition } from './definition.ts'
@@ -3,8 +3,10 @@
3
3
  * 接到官方客户端上——**格式二(DSH 工具)由格式一(MCP tools/list)引出**,
4
4
  * 与思源「MCP 为主源、CLI 为兜底」同构,且不额外起进程。
5
5
  *
6
- * 工具调用的返回值经「JSON → 文本 → JSON」往返,与直连 CLI 路径形状一致;
7
- * 服务端已把工具失败表示为 `isError` 结果,这里转成异常上抛,绝不触发兜底重放
6
+ * 成功时**原样返回 canonical MCP 结果信封**(`CallToolResult`),不在此层解包:
7
+ * DSH 侧 `source: 'mcp'` 的条目由官方 `createMcpToolDefinition` 注册,它的
8
+ * `call` 必须返回该信封(裸值会被判为 invalid MCP result 或渲染成空内容)。
9
+ * 失败信封(`isError`)在此转成异常上抛,绝不触发 CLI 兜底重放
8
10
  * (写操作重放会造成重复记账)。
9
11
  * @module @dsh-plus/actual/mcp-session
10
12
  */
@@ -13,7 +15,6 @@ import {
13
15
  type CapabilityEntry,
14
16
  createActualMcpServer,
15
17
  type EntryInvoker,
16
- parseJsonOrText,
17
18
  } from '@dsh-plus/actual-mcp'
18
19
  import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
19
20
 
@@ -24,6 +25,7 @@ export interface McpSessionLike {
24
25
  connect(timeoutMs: number): Promise<void>
25
26
  /** `tools/list` → 能力条目(`source` 由调用方补齐)。 */
26
27
  listTools(): Promise<Pick<CapabilityEntry, 'name' | 'description' | 'inputSchema'>[]>
28
+ /** 执行一次工具:成功返回 canonical MCP 结果信封,失败(`isError`)抛错。 */
27
29
  callTool(
28
30
  name: string,
29
31
  args: Record<string, unknown>,
@@ -119,9 +121,9 @@ class InProcessSession implements McpSessionLike {
119
121
  }
120
122
  }
121
123
  const result = await client.callTool({ name, arguments: args }, requestOptions)
122
- const text = textOf(result)
123
- if ((result as { isError?: unknown }).isError === true) throw new Error(text)
124
- return parseJsonOrText(text)
124
+ // 成功即原样交回信封;仅失败信封升级为异常(调用方据此报错,且不触发兜底重放)。
125
+ if ((result as { isError?: unknown }).isError === true) throw new Error(textOf(result))
126
+ return result
125
127
  }
126
128
 
127
129
  async close(): Promise<void> {
package/src/prompt.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  export const DEFAULT_PERSONA_PREFIX = `You are an Actual Budget operations agent powered by the {{model}} model. Your working directory is {{cwd}}. You operate a self-hosted Actual Budget server through the Actual tools auto-derived from the installed CLI version, plus the session's auxiliary ask/todo/web tools — there is no shell, generic file editing, or subagent capability here, and none is needed.
19
19
 
20
20
  ## Domain concepts
21
- - Budget: identified by a sync ID. The server is a CRDT sync relay that stores the budget file; all budget logic runs client-side. This session is bound to ONE budget — never switch to or download another one on your own.
21
+ - Budget: identified by a sync ID. The server is a CRDT sync relay that stores the budget file; all budget logic runs client-side. This session is bound to ONE budget — never switch to or download another one on your own. If a command reports a missing budget, the configured sync ID is wrong: it must be the groupId listed by budgets list, not that same entry's cloudFileId. Say so and stop instead of switching budgets.
22
22
  - Amounts are ALWAYS integer cents in tool arguments and in raw JSON output: 5000 = 50.00, -12350 = -123.50. Table/CSV output decimalizes, JSON never does. Convert when talking to a human, and never pass a decimal amount.
23
23
  - Accounts are on-budget by default or off-budget (tracking only); closed accounts are hidden from lists unless requested. A balance is the signed sum of that account's transactions.
24
24
  - Categories belong to category groups; both can be hidden. category.name is null for uncategorized transactions.
package/src/refs.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 凭据引用名字面量(纯常量,零依赖)。
3
+ *
4
+ * 宿主半(credentials.ts 的 seam 解析)与浏览器半(配置卡片的
5
+ * `remote.credentials` 读写)共用同一份取值;浏览器包不得经此路径拖入
6
+ * `@dsh-plus/actual-mcp` 的 node 侧实现(child_process 等),故字面量独立成模块。
7
+ *
8
+ * **必须与 `@dsh-plus/actual-mcp` 的 `SECRET_ENV` 逐字一致**:引用名与官方 CLI
9
+ * 原生读取的环境变量名同形(`ACTUAL_` 前缀亦符合《插件存储规范》的
10
+ * `<PLUGIN_ID>_` 大写约定),凭据文件里的一处配置才能同时被 seam 解析、
11
+ * 又被 CLI 自身继承环境读到。该一致性由 `tests/refs.test.ts` 钉住。
12
+ * @module @dsh-plus/actual/refs
13
+ */
14
+
15
+ /** 服务器口令。 */
16
+ export const REF_PASSWORD = 'ACTUAL_PASSWORD'
17
+ /** 会话令牌(官方 CLI 优先于口令)。 */
18
+ export const REF_SESSION_TOKEN = 'ACTUAL_SESSION_TOKEN'
19
+ /** 端到端加密口令(仅加密预算需要)。 */
20
+ export const REF_ENCRYPTION_PASSWORD = 'ACTUAL_ENCRYPTION_PASSWORD'
21
+
22
+ /** 卡片展示与批量 describe 使用的引用清单(顺序即展示顺序)。 */
23
+ export const CREDENTIAL_REFS = [REF_PASSWORD, REF_SESSION_TOKEN, REF_ENCRYPTION_PASSWORD] as const
package/src/runtime.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type CapabilityEntry,
14
14
  type CliConfig,
15
15
  type CliDeps,
16
+ callToolResultOf,
16
17
  computeDrift,
17
18
  createEntryInvoker,
18
19
  discover,
@@ -137,6 +138,11 @@ export class ActualRuntime {
137
138
  /**
138
139
  * 执行一次能力调用:**MCP 优先,CLI 兜底**。
139
140
  *
141
+ * 返回值形状由 `entry.source` 决定(它同时决定子插件用哪个注册器):
142
+ * `'mcp'` → canonical MCP 结果信封(官方 `createMcpToolDefinition` 要求);
143
+ * `'cli'` → CLI 原始值(裸 ToolDefinition 自行渲染)。兜底路径也必须守这条
144
+ * 契约——否则会话掉线后同一个工具会因形状不同而失败。
145
+ *
140
146
  * 兜底只在 MCP 会话本身不可用时触发——工具失败已由服务端表示为错误结果并
141
147
  * 转成异常,绝不重放(写操作重放会造成重复记账)。
142
148
  */
@@ -150,7 +156,8 @@ export class ActualRuntime {
150
156
  }
151
157
  if (entry.cli !== undefined && this.cliInvoker !== undefined) {
152
158
  this.logger.warn(`Actual MCP 会话不可用,降级为 CLI 直连执行:${entry.name}`)
153
- return await this.cliInvoker(entry, args, options)
159
+ const value = await this.cliInvoker(entry, args, options)
160
+ return entry.source === 'mcp' ? callToolResultOf(value) : value
154
161
  }
155
162
  throw new Error(
156
163
  `Actual MCP 不可达且该能力无 CLI 回退:${entry.name}(${this.lastError ?? '未连接'})`,
package/src/service.ts CHANGED
@@ -8,7 +8,10 @@
8
8
  * 后立即按新配置重新发现。纯展示/策略字段(name/description/order/persona 等)
9
9
  * 不触发重建,它们在下一次预设注册或工具注册时自然生效。
10
10
  *
11
- * 密钥只经子进程环境变量下发;日志与状态均经脱敏。数据目录默认落在插件数据
11
+ * 密钥解析每次操作即时进行:行级 Config 声明的优先,否则问官方 credentials
12
+ * seam(`$DSH_HOME/.credentials.yaml`,浏览器半经官方 `remote.credentials`
13
+ * 命名空间写入)——改口令后下一次工具调用即生效,无需 /reload 或重启。
14
+ * 日志与状态均经脱敏。数据目录默认落在插件数据
12
15
  * 目录(与用户自有 CLI 的 `~/.actual-cli/data` 隔离,避免缓存与迁移互相踩)。
13
16
  * @module @dsh-plus/actual/service
14
17
  */
@@ -19,11 +22,13 @@ import {
19
22
  type ActualStatus,
20
23
  type CapabilityEntry,
21
24
  type CliConfig,
25
+ configSecretsOf,
22
26
  createNodeDeps,
23
27
  } from '@dsh-plus/actual-mcp'
24
28
  import { pluginDataPath, unwrapVolatile } from '@dsh-plus/shared'
25
29
 
26
30
  import type { ActualConfig, ActualConfigFields } from './config.ts'
31
+ import { type CredentialsFace, resolveSecrets } from './credentials.ts'
27
32
  import { createInProcessSession } from './mcp-session.ts'
28
33
  import {
29
34
  ActualRuntime,
@@ -102,7 +107,12 @@ export class ActualService extends Service {
102
107
  cliVersionPolicy: config.cliVersionPolicy,
103
108
  toolCallTimeoutMs: config.toolCallTimeoutMs,
104
109
  }
105
- const runtime = new ActualRuntime(runtimeConfig, this.deps, this.logger)
110
+ // 密钥按调用即时解析:`cli` 随签名变化换代,闭包始终指向当前那一代配置。
111
+ const deps: RuntimeDeps = {
112
+ ...this.deps,
113
+ resolveSecrets: () => resolveSecrets(this.credentialsSeam(), configSecretsOf(cli)),
114
+ }
115
+ const runtime = new ActualRuntime(runtimeConfig, deps, this.logger)
106
116
  const detach = runtime.onChange((manifest) => {
107
117
  for (const listener of [...this.listeners]) listener(manifest)
108
118
  })
@@ -158,6 +168,18 @@ export class ActualService extends Service {
158
168
  return () => this.listeners.delete(cb)
159
169
  }
160
170
 
171
+ /**
172
+ * credentials seam:**可选探测**而非硬 inject。
173
+ *
174
+ * 本插件在没挂 `dsh-credentials` 的 profile(headless/dev 精简组合)里也要能
175
+ * 起来,只是密钥退回「行级 Config + CLI 自身继承环境」;且探测发生在每次操作,
176
+ * seam 比本插件晚激活也能自然接上,不必声明时序。
177
+ */
178
+ private credentialsSeam(): CredentialsFace | null {
179
+ const found = (this.ctx as unknown as { get(key: string): unknown }).get('credentials')
180
+ return found === undefined || found === null ? null : (found as CredentialsFace)
181
+ }
182
+
161
183
  dispose(): void {
162
184
  this.listeners.clear()
163
185
  this.detach()