@dsh-plus/secret-env 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.
@@ -1,22 +1,26 @@
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
- import { SecretsSection } from './section.tsx'
15
+ import { SecretMenu, type TokenSpanLike } from './menu.tsx'
16
+ import { SessionPanelHost } from './panel-host.tsx'
17
+ import { SecretsSection, type SessionsListLike } from './section.tsx'
14
18
  import { injectSecretEnvStyle } from './styles.ts'
15
19
 
16
20
  export const name = 'dsh-plus-secret-env'
17
21
 
18
22
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
19
- export const inject = ['slots', 'locale'] as const
23
+ export const inject = ['slots', 'locale', 'sessions'] as const
20
24
 
21
25
  interface SlotsLike {
22
26
  inject(key: string, callback: () => unknown): unknown
@@ -28,9 +32,21 @@ interface LocaleLike {
28
32
  bind(ns: string): (key: string) => string
29
33
  }
30
34
 
35
+ /** 会话作用域句柄的结构子集(dsh-api-session-controller 客户端 sessions 服务)。 */
36
+ interface SessionScopeLike {
37
+ bail(subject: unknown, event: string, payload: Record<string, unknown>): unknown
38
+ }
39
+
40
+ interface SessionsLike extends SessionsListLike {
41
+ scope(sessionId: string): SessionScopeLike | undefined
42
+ subagentAddress?(sessionId: string): unknown
43
+ }
44
+
31
45
  interface ClientContext {
32
46
  slots: SlotsLike
33
47
  locale: LocaleLike
48
+ sessions: SessionsLike
49
+ inject(keys: readonly string[], callback: (scope: never) => void): unknown
34
50
  effect(execute: () => () => void, label?: string): unknown
35
51
  }
36
52
 
@@ -54,24 +70,53 @@ export function apply(ctx: Context): void {
54
70
  id: 'dsh-plus-secret-env',
55
71
  order: 16,
56
72
  label: () => t('nav'),
57
- inject: () => ({ t }),
73
+ inject: () => ({ t, sessions: c.sessions }),
58
74
  },
59
75
  SecretsSection,
60
76
  ),
61
77
  )
62
78
 
63
- // composer 工具行钥匙胶囊(conversation.input.right:session 作用域 list 槽,
64
- // inject 工厂收框架解析的 sessionId)。
65
- c.slots.inject('conversation.input.right', () =>
79
+ // `/var` 斜杠命令(commandUi dsh-client-ui-commands 客户端半提供)。
80
+ registerVarCommand(c, c.sessions, t)
81
+
82
+ // `$` 触发补全菜单(conversation.input.overlay:composer 卡片内浮层)。
83
+ // 插入走官方会话作用域 bail 通道 slash/input-insert-text(span+draftRev CAS)。
84
+ c.slots.inject('conversation.input.overlay', () =>
66
85
  c.slots.register(
67
86
  {
68
- name: 'conversation.input.right',
69
- id: 'dsh-plus-secret-env',
70
- order: 40,
87
+ name: 'conversation.input.overlay',
88
+ id: 'dse-secret-menu',
89
+ order: 10,
90
+ locale: NS,
91
+ inject: (sessionId: string) => ({
92
+ sessionId,
93
+ insertToken: (text: string, span: TokenSpanLike): boolean => {
94
+ const actx = c.sessions.scope(sessionId)
95
+ if (actx === undefined) return false
96
+ return (
97
+ actx.bail(actx, 'slash/input-insert-text', {
98
+ text,
99
+ span: { start: span.start, end: span.end, draftRev: span.draftRev },
100
+ }) === true
101
+ )
102
+ },
103
+ }),
104
+ },
105
+ SecretMenu,
106
+ ),
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,
71
116
  locale: NS,
72
117
  inject: (sessionId: string) => ({ sessionId }),
73
118
  },
74
- ComposerSecret,
119
+ SessionPanelHost,
75
120
  ),
76
121
  )
77
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,23 +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: '继承',
63
+ // `$` 触发补全菜单
64
+ 'menu.title': '变量',
65
+ 'menu.aria': '变量候选',
66
+ // /var 斜杠命令
67
+ 'command.description': '管理本会话的环境变量(密钥、链接等)',
68
+ 'command.openPanel': '打开会话变量面板',
50
69
  // 错误码
51
70
  'error.invalid-name': '变量名不合法:大写字母开头,仅字母/数字/下划线,≤64 字符。',
52
71
  'error.empty-value': '值不能为空。',
@@ -58,22 +77,18 @@ const ownZh = {
58
77
  }
59
78
 
60
79
  const ownEn = {
61
- nav: 'Secret Variables',
62
- title: 'Secret Variables',
80
+ nav: 'Env Variables',
81
+ title: 'Env Variables',
63
82
  description:
64
- '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.',
65
- globalList: 'Global secrets',
66
- colName: 'Variable (model-visible)',
67
- colDescription: 'Description',
68
- colState: 'State',
69
- 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',
70
85
  configured: 'Configured',
71
86
  unconfigured: 'Not configured',
72
87
  readonly: 'Read-only source',
73
- addGlobal: 'Add global secret',
74
- nameLabel: 'Name suffix',
88
+ addGlobal: 'Add global variable',
89
+ nameLabel: 'Variable name',
75
90
  nameHint:
76
- '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.',
77
92
  valueLabel: 'Value',
78
93
  valueHint: 'Write-only after saving; stored in the host credential store, never in chat.',
79
94
  descLabel: 'Description (optional)',
@@ -81,23 +96,42 @@ const ownEn = {
81
96
  save: 'Save',
82
97
  saving: 'Saving…',
83
98
  delete: 'Delete',
84
- deleteConfirm: 'Delete {name}? The variable stops working for subsequent commands immediately.',
85
99
  copy: 'Copy variable name',
86
100
  copied: 'Copied',
87
- 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.',
88
102
  loadFailed: 'Failed to load. Try again later.',
89
103
  retry: 'Retry',
90
- sessionSecrets: 'Session secrets',
91
- 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.',
92
117
  sessionHint:
93
118
  'Available to shell commands of this session only; expires when the session ends or after one use.',
94
- addSession: 'Add session secret',
119
+ addSession: 'Add session variable',
95
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',
96
125
  close: 'Close',
97
126
  refresh: 'Refresh',
98
127
  scopeGlobal: 'global',
99
128
  scopeSession: 'session',
100
129
  scopeOnce: 'once',
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',
101
135
  'error.invalid-name':
102
136
  'Invalid name: start with an uppercase letter; letters/digits/underscore only, ≤64 chars.',
103
137
  'error.empty-value': 'Value must not be empty.',
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `$` 触发补全的纯核心(零 DOM / React / cordis,node 直接单测):
3
+ * - detectSecretTrigger:光标处 `$<query>` 命中检测(边界规则对齐官方
4
+ * input-trigger 的 / 与 @:起草开头、空白后、标点后开闸;词中 $ 不触发);
5
+ * - filterCandidates:按 query 过滤+排序(前缀命中优先);
6
+ * - applyChipCorrection:渲染文本偏移 → 草稿投影偏移的芯片修正。
7
+ * @module secret-env/client/menu-core
8
+ */
9
+
10
+ /** 一次 `$` 命中:query 为 $ 到光标间的文本,[start, end) 为草稿投影中的令牌区间。 */
11
+ export interface SecretHit {
12
+ readonly query: string
13
+ readonly start: number
14
+ readonly end: number
15
+ }
16
+
17
+ /** 菜单候选项(全局/会话/继承密钥的统一视图,纯展示数据)。 */
18
+ export interface SecretCandidate {
19
+ readonly envName: string
20
+ readonly name: string
21
+ readonly description: string
22
+ readonly scope: 'global' | 'session' | 'inherited'
23
+ readonly once?: boolean
24
+ }
25
+
26
+ const WORD_CHAR = /[\p{L}\p{N}_]/u
27
+ const TOKEN_CHAR = /^[A-Za-z0-9_]$/
28
+
29
+ /**
30
+ * 检测光标处的 `$` 触发令牌。词法:
31
+ * - 从光标向左扫描,token 字符(字母/数字/下划线)继续,空白直接判负;
32
+ * - 遇到 `$` 时校验词边界:起草开头 / 前字符为空白 / 前字符为非词字符(标点)
33
+ * 才开闸——`foo$BAR` 这类词中 $ 不触发(与官方 URL 内 / 不触发同理);
34
+ * - 其余字符(如 `{`、`(`)终止扫描。
35
+ */
36
+ export function detectSecretTrigger(draft: string, caret: number): SecretHit | null {
37
+ if (caret <= 0 || caret > draft.length) return null
38
+ for (let i = caret - 1; i >= 0; i--) {
39
+ const ch = draft.charAt(i)
40
+ if (/\s/u.test(ch)) return null
41
+ if (ch !== '$') {
42
+ if (!TOKEN_CHAR.test(ch)) return null
43
+ continue
44
+ }
45
+ if (i > 0) {
46
+ const prev = draft.charAt(i - 1)
47
+ if (!/\s/u.test(prev) && WORD_CHAR.test(prev)) return null
48
+ }
49
+ return { query: draft.slice(i + 1, caret), start: i, end: caret }
50
+ }
51
+ return null
52
+ }
53
+
54
+ /** 两个命中的等价判定(抑制重复 setState)。 */
55
+ export function sameHit(a: SecretHit | null, b: SecretHit | null): boolean {
56
+ if (a === null || b === null) return a === b
57
+ return a.query === b.query && a.start === b.start && a.end === b.end
58
+ }
59
+
60
+ /** 按 query 过滤候选(大小写不敏感;变量名前缀命中排前,其余子串命中随后)。 */
61
+ export function filterCandidates(
62
+ entries: readonly SecretCandidate[],
63
+ query: string,
64
+ ): SecretCandidate[] {
65
+ const q = query.toUpperCase()
66
+ const prefixed: SecretCandidate[] = []
67
+ const partial: SecretCandidate[] = []
68
+ for (const entry of entries) {
69
+ if (q === '') {
70
+ prefixed.push(entry)
71
+ continue
72
+ }
73
+ if (entry.envName.toUpperCase().includes(q) || entry.name.toUpperCase().includes(q)) {
74
+ if (entry.name.toUpperCase().startsWith(q) || entry.envName.toUpperCase().includes(`_${q}`)) {
75
+ prefixed.push(entry)
76
+ } else {
77
+ partial.push(entry)
78
+ }
79
+ }
80
+ }
81
+ return [...prefixed, ...partial]
82
+ }
83
+
84
+ /** 一处芯片修正对:光标前芯片的渲染文本长与草稿投影长。 */
85
+ export interface ChipCorrection {
86
+ readonly rendered: number
87
+ readonly draft: number
88
+ }
89
+
90
+ /**
91
+ * 渲染文本偏移 → 草稿投影偏移。contenteditable 中芯片(引用装饰器)的
92
+ * 渲染文本与其草稿投影长度不等,光标渲染偏移需逐芯片修正。
93
+ */
94
+ export function applyChipCorrection(
95
+ renderedOffset: number,
96
+ chipsBeforeCaret: readonly ChipCorrection[],
97
+ ): number {
98
+ let offset = renderedOffset
99
+ for (const chip of chipsBeforeCaret) {
100
+ offset -= chip.rendered - chip.draft
101
+ }
102
+ return Math.max(0, offset)
103
+ }