@dsh-plus/secret-env 0.1.1 → 0.1.3

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.
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * 浏览器半入口:
3
- * - settings.section 官方插槽注册「密钥变量」独立设置页(全局管理);
4
- * - conversation.input.right session 作用域插槽注册 composer 钥匙胶囊
5
- * (会话级密钥面板;会话 id 由插槽 inject 工厂直接供给)。
3
+ * - settings.section 官方插槽注册「环境变量」独立设置页
4
+ * (全局管理 + 继承变量屏蔽 + 会话变量管理);
5
+ * - conversation.input.overlay 注册两个 session 作用域浮层:
6
+ * `$` 触发补全菜单与 /var 命令唤起的会话变量面板;
7
+ * - commandUi 注册 /var 斜杠命令(官方 popupSelect 壳 → 打开总线 → 面板)。
6
8
  * 数据均经同源 HTTP 端点(值不上行经任何对话通道)。
7
9
  * @module @dsh-plus/secret-env/client
8
10
  */
9
11
  import type { Context } from '@deepseek-ai/cordis'
10
12
 
11
- import { ComposerSecret } from './composer.tsx'
13
+ import { registerVarCommand } from './command.ts'
12
14
  import { en, NS, zh } from './i18n.ts'
13
15
  import { SecretMenu, type TokenSpanLike } from './menu.tsx'
14
- import { SecretsSection } from './section.tsx'
16
+ import { SessionPanelHost } from './panel-host.tsx'
17
+ import { SecretsSection, type SessionsListLike } from './section.tsx'
15
18
  import { injectSecretEnvStyle } from './styles.ts'
16
19
 
17
20
  export const name = 'dsh-plus-secret-env'
@@ -34,14 +37,16 @@ interface SessionScopeLike {
34
37
  bail(subject: unknown, event: string, payload: Record<string, unknown>): unknown
35
38
  }
36
39
 
37
- interface SessionsLike {
40
+ interface SessionsLike extends SessionsListLike {
38
41
  scope(sessionId: string): SessionScopeLike | undefined
42
+ subagentAddress?(sessionId: string): unknown
39
43
  }
40
44
 
41
45
  interface ClientContext {
42
46
  slots: SlotsLike
43
47
  locale: LocaleLike
44
48
  sessions: SessionsLike
49
+ inject(keys: readonly string[], callback: (scope: never) => void): unknown
45
50
  effect(execute: () => () => void, label?: string): unknown
46
51
  }
47
52
 
@@ -65,26 +70,14 @@ export function apply(ctx: Context): void {
65
70
  id: 'dsh-plus-secret-env',
66
71
  order: 16,
67
72
  label: () => t('nav'),
68
- inject: () => ({ t }),
73
+ inject: () => ({ t, sessions: c.sessions }),
69
74
  },
70
75
  SecretsSection,
71
76
  ),
72
77
  )
73
78
 
74
- // composer 工具行钥匙胶囊(conversation.input.right:session 作用域 list 槽,
75
- // inject 工厂收框架解析的 sessionId)。
76
- c.slots.inject('conversation.input.right', () =>
77
- c.slots.register(
78
- {
79
- name: 'conversation.input.right',
80
- id: 'dsh-plus-secret-env',
81
- order: 40,
82
- locale: NS,
83
- inject: (sessionId: string) => ({ sessionId }),
84
- },
85
- ComposerSecret,
86
- ),
87
- )
79
+ // `/var` 斜杠命令(commandUi dsh-client-ui-commands 客户端半提供)。
80
+ registerVarCommand(c, c.sessions, t)
88
81
 
89
82
  // `$` 触发补全菜单(conversation.input.overlay:composer 卡片内浮层)。
90
83
  // 插入走官方会话作用域 bail 通道 slash/input-insert-text(span+draftRev CAS)。
@@ -112,4 +105,18 @@ export function apply(ctx: Context): void {
112
105
  SecretMenu,
113
106
  ),
114
107
  )
108
+
109
+ // `/var` 唤起的会话变量面板(同一 overlay 槽,监听打开总线)。
110
+ c.slots.inject('conversation.input.overlay', () =>
111
+ c.slots.register(
112
+ {
113
+ name: 'conversation.input.overlay',
114
+ id: 'dse-secret-panel',
115
+ order: 20,
116
+ locale: NS,
117
+ inject: (sessionId: string) => ({ sessionId }),
118
+ },
119
+ SessionPanelHost,
120
+ ),
121
+ )
115
122
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `/var` 斜杠命令(dsh-client-ui-commands 的 commandUi 服务):
3
+ * contribution 仅支持官方 popupSelect 壳(kind 唯一),故命令提供单个
4
+ * 「打开面板」动作,选中后经打开总线唤起本会话的变量面板——
5
+ * 完全自定义的 React 面板只能在自己占有的 overlay 槽渲染。
6
+ * 子代理会话不提供本命令(对齐官方 /model 的可用性过滤)。
7
+ * @module secret-env/client/command
8
+ */
9
+ import { requestOpenSessionPanel } from './panel-bus.ts'
10
+
11
+ /** commandUi 服务的结构子集(dsh-client-ui-commands CommandUiContract)。 */
12
+ interface CommandUiLike {
13
+ register(contribution: {
14
+ name: string
15
+ description: string
16
+ available(session: { sessionId: string }): boolean
17
+ ui: {
18
+ kind: 'popupSelect'
19
+ options(session: { sessionId: string }): Promise<readonly { id: string; label: string }[]>
20
+ onSelect(option: { id: string }, session: { sessionId: string }): void | Promise<void>
21
+ }
22
+ }): () => void
23
+ }
24
+
25
+ interface CommandScope {
26
+ commandUi: CommandUiLike
27
+ effect(execute: () => () => void, label?: string): unknown
28
+ }
29
+
30
+ interface CommandHostContext {
31
+ inject(keys: readonly string[], callback: (scope: CommandScope) => void): unknown
32
+ }
33
+
34
+ interface SessionsAvailabilityLike {
35
+ subagentAddress?(sessionId: string): unknown
36
+ }
37
+
38
+ /** 注册 /var 命令(commandUi 服务缺席时 inject 回调不触发,自然跳过)。 */
39
+ export function registerVarCommand(
40
+ ctx: CommandHostContext,
41
+ sessions: SessionsAvailabilityLike,
42
+ t: (key: string) => string,
43
+ ): void {
44
+ ctx.inject(['commandUi'], (scope) => {
45
+ scope.effect(
46
+ () =>
47
+ scope.commandUi.register({
48
+ name: 'var',
49
+ description: t('command.description'),
50
+ available: (session) => sessions.subagentAddress?.(session.sessionId) === undefined,
51
+ ui: {
52
+ kind: 'popupSelect',
53
+ options: () => Promise.resolve([{ id: 'open', label: t('command.openPanel') }]),
54
+ onSelect: (_option, session) => {
55
+ requestOpenSessionPanel(session.sessionId)
56
+ },
57
+ },
58
+ }),
59
+ 'secret-env: /var contribution',
60
+ )
61
+ })
62
+ }
@@ -1,7 +1,9 @@
1
1
  /**
2
- * 浏览器半共享小件:错误码文案映射与复制助手(设置页与会话控件共用)。
2
+ * 浏览器半共享小件:错误码文案映射、复制助手与变量名输入规则
3
+ * (设置页与会话控件共用;名称规则与 node 半 names.ts 同源)。
3
4
  * @module secret-env/client/common
4
5
  */
6
+ import { type NameError, normalizeSuffix, validateSuffix } from '../names.ts'
5
7
  import { ApiError } from './api.ts'
6
8
 
7
9
  /** 端点错误 → 本地化文案(未知码回落 internal)。 */
@@ -12,6 +14,17 @@ export function errorText(t: (key: string) => string, error: unknown): string {
12
14
  return text === `error.${code}` ? t('error.internal') : text
13
15
  }
14
16
 
17
+ /** 名称输入的实时规范化:键入即转大写(去空白交给保存时的 normalizeSuffix)。 */
18
+ export function liveName(raw: string): string {
19
+ return raw.toUpperCase()
20
+ }
21
+
22
+ /** 名称输入的实时校验:空串不标错(必填兜底),其余按 names.ts 规则判定。 */
23
+ export function nameErrorOf(raw: string): NameError | null {
24
+ if (raw.trim() === '') return null
25
+ return validateSuffix(normalizeSuffix(raw))
26
+ }
27
+
15
28
  /** 复制文本到剪贴板(clipboard API 缺席时退 execCommand)。 */
16
29
  export async function copyText(text: string): Promise<boolean> {
17
30
  try {
@@ -7,22 +7,18 @@ import { commonEn, commonZh, mergeDict } from '@dsh-plus/shared/client'
7
7
  export const NS = 'dsh-plus-secret-env'
8
8
 
9
9
  const ownZh = {
10
- nav: '密钥变量',
11
- title: '密钥变量',
10
+ nav: '环境变量',
11
+ title: '环境变量',
12
12
  description:
13
- '以 $DSH_SECRET_* 变量名向 agent 暴露密钥:值在每次 shell 调用时由宿主注入,不进入对话记录与提示词,不影响上下文缓存。',
13
+ '以 $DSH_VAR_* 变量名向 agent 暴露值(密钥、链接等):值在每次 shell 调用时由宿主注入,不进入对话记录与提示词,不影响上下文缓存。',
14
14
  // 设置页(全局)
15
- globalList: '全局密钥',
16
- colName: '变量名(模型可见)',
17
- colDescription: '描述',
18
- colState: '状态',
19
- colActions: '操作',
15
+ globalList: '全局变量',
20
16
  configured: '已配置',
21
17
  unconfigured: '未配置',
22
18
  readonly: '只读来源',
23
- addGlobal: '添加全局密钥',
24
- nameLabel: '变量名后缀',
25
- nameHint: '大写字母/数字/下划线,如 GITHUB_TOKEN;模型实际使用 $DSH_SECRET_ 前缀拼接后的完整名。',
19
+ addGlobal: '添加全局变量',
20
+ nameLabel: '变量名',
21
+ nameHint: '大写字母/数字/下划线,如 GITHUB_TOKEN;模型实际使用 $DSH_VAR_ 前缀拼接后的完整名。',
26
22
  valueLabel: '值',
27
23
  valueHint: '写入后不可回读;保存于宿主凭据库,不进入对话。',
28
24
  descLabel: '描述(可选)',
@@ -30,26 +26,46 @@ const ownZh = {
30
26
  save: '保存',
31
27
  saving: '保存中…',
32
28
  delete: '删除',
33
- deleteConfirm: '确认删除 {name}?该变量将立即对后续命令失效。',
34
29
  copy: '复制变量名',
35
30
  copied: '已复制',
36
- emptyGlobal: '暂无全局密钥。添加后 agent 即可通过变量名引用。',
31
+ emptyGlobal: '暂无全局变量。添加后 agent 即可通过变量名引用。',
37
32
  loadFailed: '加载失败,请稍后重试。',
38
33
  retry: '重试',
39
- // 会话注入控件
40
- sessionSecrets: '会话密钥',
41
- sessionEmpty: '本会话暂无有默认值的密钥。',
34
+ // 继承变量
35
+ inheritedList: '继承变量(宿主环境)',
36
+ inheritedNote: '来自宿主进程环境,默认纳入注入',
37
+ inheritedEmpty: '宿主环境中没有可继承的 DSH_VAR_* 变量。',
38
+ maskGlobal: '全局屏蔽',
39
+ unmaskGlobal: '恢复注入',
40
+ maskedBadge: '已屏蔽',
41
+ maskedGlobalBadge: '已全局屏蔽',
42
+ // 设置页会话管理
43
+ sessionManage: '会话变量',
44
+ sessionPick: '选择会话',
45
+ sessionPickEmpty: '暂无会话',
46
+ // 会话面板
47
+ sessionSecrets: '会话变量',
48
+ sessionList: '会话变量',
49
+ sessionEmpty: '本会话暂无变量。',
42
50
  sessionHint: '仅当前会话的 shell 命令可用;会话结束或一次性使用即失效。',
43
- addSession: '添加会话密钥',
51
+ addSession: '添加会话变量',
44
52
  onceLabel: '一次性(首次使用后销毁)',
53
+ globalInSession: '全局变量(可在本会话屏蔽)',
54
+ inheritedInSession: '继承变量(可在本会话屏蔽)',
55
+ maskSession: '在本会话屏蔽',
56
+ unmaskSession: '在本会话恢复',
45
57
  close: '关闭',
46
58
  refresh: '刷新',
47
59
  scopeGlobal: '全局',
48
60
  scopeSession: '会话',
49
61
  scopeOnce: '一次性',
62
+ scopeInherited: '继承',
50
63
  // `$` 触发补全菜单
51
- 'menu.title': '密钥变量',
52
- 'menu.aria': '密钥变量候选',
64
+ 'menu.title': '变量',
65
+ 'menu.aria': '变量候选',
66
+ // /var 斜杠命令
67
+ 'command.description': '管理本会话的环境变量(密钥、链接等)',
68
+ 'command.openPanel': '打开会话变量面板',
53
69
  // 错误码
54
70
  'error.invalid-name': '变量名不合法:大写字母开头,仅字母/数字/下划线,≤64 字符。',
55
71
  'error.empty-value': '值不能为空。',
@@ -61,22 +77,18 @@ const ownZh = {
61
77
  }
62
78
 
63
79
  const ownEn = {
64
- nav: 'Secret Variables',
65
- title: 'Secret Variables',
80
+ nav: 'Env Variables',
81
+ title: 'Env Variables',
66
82
  description:
67
- 'Expose secrets to the agent as $DSH_SECRET_* variables: values are injected by the host into each shell call, never enter the transcript or prompt, and do not affect context caching.',
68
- globalList: 'Global secrets',
69
- colName: 'Variable (model-visible)',
70
- colDescription: 'Description',
71
- colState: 'State',
72
- colActions: 'Actions',
83
+ 'Expose values (keys, links, etc.) to the agent as $DSH_VAR_* variables: values are injected by the host into each shell call, never enter the transcript or prompt, and do not affect context caching.',
84
+ globalList: 'Global variables',
73
85
  configured: 'Configured',
74
86
  unconfigured: 'Not configured',
75
87
  readonly: 'Read-only source',
76
- addGlobal: 'Add global secret',
77
- nameLabel: 'Name suffix',
88
+ addGlobal: 'Add global variable',
89
+ nameLabel: 'Variable name',
78
90
  nameHint:
79
- 'Uppercase letters/digits/underscore, e.g. GITHUB_TOKEN; the model uses the full name with the $DSH_SECRET_ prefix.',
91
+ 'Uppercase letters/digits/underscore, e.g. GITHUB_TOKEN; the model uses the full name with the $DSH_VAR_ prefix.',
80
92
  valueLabel: 'Value',
81
93
  valueHint: 'Write-only after saving; stored in the host credential store, never in chat.',
82
94
  descLabel: 'Description (optional)',
@@ -84,25 +96,42 @@ const ownEn = {
84
96
  save: 'Save',
85
97
  saving: 'Saving…',
86
98
  delete: 'Delete',
87
- deleteConfirm: 'Delete {name}? The variable stops working for subsequent commands immediately.',
88
99
  copy: 'Copy variable name',
89
100
  copied: 'Copied',
90
- emptyGlobal: 'No global secrets yet. Once added, the agent can reference them by name.',
101
+ emptyGlobal: 'No global variables yet. Once added, the agent can reference them by name.',
91
102
  loadFailed: 'Failed to load. Try again later.',
92
103
  retry: 'Retry',
93
- sessionSecrets: 'Session secrets',
94
- sessionEmpty: 'No session secrets yet.',
104
+ inheritedList: 'Inherited variables (host env)',
105
+ inheritedNote: 'From the host process environment; injected by default',
106
+ inheritedEmpty: 'No inheritable DSH_VAR_* variables in the host environment.',
107
+ maskGlobal: 'Mask globally',
108
+ unmaskGlobal: 'Resume injection',
109
+ maskedBadge: 'Masked',
110
+ maskedGlobalBadge: 'Masked globally',
111
+ sessionManage: 'Session variables',
112
+ sessionPick: 'Select session',
113
+ sessionPickEmpty: 'No sessions',
114
+ sessionSecrets: 'Session variables',
115
+ sessionList: 'Session variables',
116
+ sessionEmpty: 'No session variables yet.',
95
117
  sessionHint:
96
118
  'Available to shell commands of this session only; expires when the session ends or after one use.',
97
- addSession: 'Add session secret',
119
+ addSession: 'Add session variable',
98
120
  onceLabel: 'One-time (destroyed after first use)',
121
+ globalInSession: 'Global variables (can be masked in this session)',
122
+ inheritedInSession: 'Inherited variables (can be masked in this session)',
123
+ maskSession: 'Mask in this session',
124
+ unmaskSession: 'Resume in this session',
99
125
  close: 'Close',
100
126
  refresh: 'Refresh',
101
127
  scopeGlobal: 'global',
102
128
  scopeSession: 'session',
103
129
  scopeOnce: 'once',
104
- 'menu.title': 'Secret variables',
105
- 'menu.aria': 'Secret variable suggestions',
130
+ scopeInherited: 'inherited',
131
+ 'menu.title': 'Variables',
132
+ 'menu.aria': 'Variable suggestions',
133
+ 'command.description': "Manage this session's env variables (keys, links, etc.)",
134
+ 'command.openPanel': 'Open the session variable panel',
106
135
  'error.invalid-name':
107
136
  'Invalid name: start with an uppercase letter; letters/digits/underscore only, ≤64 chars.',
108
137
  'error.empty-value': 'Value must not be empty.',
@@ -14,12 +14,12 @@ export interface SecretHit {
14
14
  readonly end: number
15
15
  }
16
16
 
17
- /** 菜单候选项(全局与会话密钥的统一视图,纯展示数据)。 */
17
+ /** 菜单候选项(全局/会话/继承密钥的统一视图,纯展示数据)。 */
18
18
  export interface SecretCandidate {
19
19
  readonly envName: string
20
20
  readonly name: string
21
21
  readonly description: string
22
- readonly scope: 'global' | 'session'
22
+ readonly scope: 'global' | 'session' | 'inherited'
23
23
  readonly once?: boolean
24
24
  }
25
25
 
@@ -92,12 +92,23 @@ function toCandidates(list: SecretList): SecretCandidate[] {
92
92
  scope: 'session' as const,
93
93
  once: entry.once,
94
94
  })),
95
- ...list.global.map((entry) => ({
96
- envName: entry.envName,
97
- name: entry.name,
98
- description: entry.description,
99
- scope: 'global' as const,
100
- })),
95
+ // 已屏蔽的变量不会注入,不出候选(避免误导补全)。
96
+ ...list.global
97
+ .filter((entry) => !entry.masked)
98
+ .map((entry) => ({
99
+ envName: entry.envName,
100
+ name: entry.name,
101
+ description: entry.description,
102
+ scope: 'global' as const,
103
+ })),
104
+ ...list.inherited
105
+ .filter((entry) => !entry.masked)
106
+ .map((entry) => ({
107
+ envName: entry.envName,
108
+ name: entry.name,
109
+ description: '',
110
+ scope: 'inherited' as const,
111
+ })),
101
112
  ]
102
113
  }
103
114
 
@@ -262,6 +273,7 @@ export function SecretMenu(props: SecretMenuProps): ReactElement | null {
262
273
  type="button"
263
274
  role="option"
264
275
  aria-selected={index === highlight}
276
+ title={`$${entry.envName}`}
265
277
  className={`dse-menuItem${index === highlight ? ' dse-menuItemActive' : ''}`}
266
278
  onMouseEnter={() => setHighlight(index)}
267
279
  onMouseDown={(event) => {
@@ -269,12 +281,16 @@ export function SecretMenu(props: SecretMenuProps): ReactElement | null {
269
281
  pick(entry)
270
282
  }}
271
283
  >
272
- <span className="dse-menuName">${entry.envName}</span>
284
+ <span className="dse-menuName">{entry.name}</span>
273
285
  <span className="dse-menuDesc">{entry.description}</span>
274
286
  <span className="dse-menuBadges">
275
287
  {entry.once === true ? <span className="dse-badge">{t('scopeOnce')}</span> : null}
276
288
  <span className="dse-badge dse-badgeDim">
277
- {entry.scope === 'session' ? t('scopeSession') : t('scopeGlobal')}
289
+ {entry.scope === 'session'
290
+ ? t('scopeSession')
291
+ : entry.scope === 'inherited'
292
+ ? t('scopeInherited')
293
+ : t('scopeGlobal')}
278
294
  </span>
279
295
  </span>
280
296
  </button>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 会话密钥面板的打开总线(bundle 内模块级 pub/sub):
3
+ * 斜杠命令的 onSelect 与 overlay 槽里的面板宿主在同一 bundle,
4
+ * 经此按 sessionId 定向投递「打开面板」信号,避免跨 cordis 作用域布线。
5
+ * @module secret-env/client/panel-bus
6
+ */
7
+
8
+ type Listener = (sessionId: string) => void
9
+
10
+ const listeners = new Set<Listener>()
11
+
12
+ /** 请求打开指定会话的密钥面板(由 /secret 命令的 onSelect 调用)。 */
13
+ export function requestOpenSessionPanel(sessionId: string): void {
14
+ for (const listener of listeners) listener(sessionId)
15
+ }
16
+
17
+ /** 订阅打开信号;返回退订器(随组件卸载清理)。 */
18
+ export function onOpenSessionPanel(listener: Listener): () => void {
19
+ listeners.add(listener)
20
+ return () => {
21
+ listeners.delete(listener)
22
+ }
23
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 会话变量面板的 overlay 宿主(conversation.input.overlay 官方插槽,
3
+ * session 作用域):常驻监听打开总线,/var 命令选中后弹出面板。
4
+ * 弹层定位于 composer 卡片上方(与 $ 菜单同一浮层语言);
5
+ * 点外部或 Esc 关闭。闭合态不渲染面板(数据在打开时现取)。
6
+ * @module secret-env/client/panel-host
7
+ */
8
+ import { type ReactElement, useEffect, useRef, useState } from 'react'
9
+
10
+ import { SessionSecretsPanel } from './panel.tsx'
11
+ import { onOpenSessionPanel } from './panel-bus.ts'
12
+
13
+ export interface SessionPanelHostProps {
14
+ /** 插槽 inject 工厂注入的当前会话 id。 */
15
+ sessionId: string
16
+ t(key: string): string
17
+ }
18
+
19
+ export function SessionPanelHost(props: SessionPanelHostProps): ReactElement | null {
20
+ const { sessionId, t } = props
21
+ const [open, setOpen] = useState(false)
22
+ const wrapRef = useRef<HTMLDivElement | null>(null)
23
+
24
+ // 打开总线:只响应本会话的定向信号。
25
+ useEffect(
26
+ () =>
27
+ onOpenSessionPanel((target) => {
28
+ if (target === sessionId) setOpen(true)
29
+ }),
30
+ [sessionId],
31
+ )
32
+
33
+ // Esc / 点外部关闭(捕获阶段,先于编辑器吞键)。
34
+ useEffect(() => {
35
+ if (!open) return
36
+ const onKey = (event: KeyboardEvent): void => {
37
+ if (event.key !== 'Escape') return
38
+ event.preventDefault()
39
+ event.stopPropagation()
40
+ setOpen(false)
41
+ }
42
+ const onDown = (event: PointerEvent): void => {
43
+ if (!(event.target instanceof Node)) return
44
+ if (wrapRef.current?.contains(event.target) === true) return
45
+ setOpen(false)
46
+ }
47
+ document.addEventListener('keydown', onKey, true)
48
+ document.addEventListener('pointerdown', onDown, true)
49
+ return () => {
50
+ document.removeEventListener('keydown', onKey, true)
51
+ document.removeEventListener('pointerdown', onDown, true)
52
+ }
53
+ }, [open])
54
+
55
+ if (!open) return null
56
+ return (
57
+ <div className="dse-panelWrap" ref={wrapRef}>
58
+ <div className="dse-panelCard" role="dialog" aria-label={t('sessionSecrets')}>
59
+ <SessionSecretsPanel sessionId={sessionId} t={t} onClose={() => setOpen(false)} />
60
+ </div>
61
+ </div>
62
+ )
63
+ }