@maci0/dsh-alias 0.5.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcel W. Wysocki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # dsh-alias
2
+
3
+ Define your own slash commands in DeepSeek Harness, from the composer or from
4
+ the Plugins page.
5
+
6
+ ## What you get
7
+
8
+ ```
9
+ /alias add gm summarize the repository in five bullets
10
+ /alias add rev /perf-review {args}
11
+ /alias add ship /cordis-review
12
+ /alias list
13
+ /alias remove gm
14
+ ```
15
+
16
+ After that, `/gm`, `/rev src/app.ts`, and `/ship` are ordinary commands: they
17
+ show up in the composer's command directory and run without a model turn of
18
+ their own. The Plugins page gains an **Alias** card that edits the same set.
19
+
20
+ ## Install
21
+
22
+ > **Install it as a bundle.** `dsh plugin add …` mounts the row from the
23
+ > package's own patch layer, which is what the settings editor can write to. A
24
+ > row added with `--patch` is an overlay: it disappears at the next start, and
25
+ > the Plugins card cannot save into it (the editor refuses a write an overlay
26
+ > would win).
27
+
28
+ ```sh
29
+ dsh plugin --profile web add @maci0/dsh-alias@0.5.2
30
+ ```
31
+
32
+ This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
33
+ The version is pinned. To upgrade, run the same command with a newer version,
34
+ then restart `dsh web` (bundle layers compose at boot).
35
+
36
+ The bundled `cordis.patch.yml` inserts the `alias` row.
37
+
38
+ ## Commands
39
+
40
+ `/alias add <name> <text>` registers `/<name>`. What `/<name>` does depends on
41
+ the text it stands for:
42
+
43
+ - **Text starting with `/` runs that command.** `/alias add rev /perf-review`
44
+ makes `/rev --staged` equivalent to `/perf-review --staged`, including the
45
+ `/perf-review` lifecycle row in the log. Nested aliases are allowed and stop
46
+ after four levels, so a cycle is reported instead of hanging.
47
+ - **Anything else is queued to the agent as a prompt.** `/alias add gm good
48
+ morning` turns `/gm now what` into a follow-up turn whose prompt is
49
+ `good morning now what`. The message is attributed to the alias rather than
50
+ to you, so mode commands such as `normal mode` inside an alias text cannot
51
+ be mistaken for your own words.
52
+
53
+ `{args}` in the text is replaced with whatever you typed after the alias name;
54
+ when the text has no placeholder and you did type arguments, they are appended.
55
+ A name is lowercase letters, digits, `_` or `-`, must start with a letter, and
56
+ may not be `alias` or shadow another registered command.
57
+
58
+ `/alias` with no verb lists the aliases; `/alias set` is a synonym for `add`,
59
+ and `rm` and `delete` are synonyms for `remove`.
60
+
61
+ ## Configure
62
+
63
+ Aliases live in one settings field, the plugin's own row:
64
+
65
+ ```yaml
66
+ - id: alias
67
+ name: '@maci0/dsh-alias'
68
+ config:
69
+ aliases:
70
+ gm: summarize the repository in five bullets
71
+ rev: /perf-review {args}
72
+ ```
73
+
74
+ ## How it works
75
+
76
+ The field is `volatile()`, so the Plugins page's **Alias** card, `/alias`, and
77
+ the profile patch are three views of one document: a write from any of them
78
+ lands on the running instance with no remount. A deployment whose settings
79
+ document refuses writes still gets working aliases in every current session
80
+ until dsh restarts, and the reply says so. The card reports a write the host refuses.
81
+
82
+ The card's **Edit** control loads a row back into the form; the same write
83
+ adds and updates, and the button says which one it will do.
84
+
85
+ ## Limits
86
+
87
+ - Aliases accept no attachments; the composer refuses a submission that carries
88
+ any, rather than dropping them silently.
89
+ - A name typed in `/alias add` must already be lowercase; the card lowercases
90
+ the name for you.
91
+
92
+ ## Development
93
+
94
+ dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on
95
+ bun.
96
+
97
+ Plain JavaScript, no build step: `bun test` runs the suite over the host
98
+ half (`index.js`) and the browser half (`lib/client.js`) against structural
99
+ fakes of the harness surfaces they use, plus a composition test that mounts the
100
+ plugin in a real `@deepseek-ai/cordis` context.
101
+
102
+ ```sh
103
+ bun install --frozen-lockfile
104
+ bun test
105
+ ```
106
+
107
+ For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
108
+
109
+ ## Licence
110
+
111
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ # Bundle layer: applied when a profile lists this bundle in dsh.profile.bundles
2
+ # (dsh plugin add does that from dsh.bundle). That list is frozen at boot.
3
+ # Do not also paste this row into the profile's cordis.patch.yml: insert does
4
+ # not dedupe ids, two rows would register the plugin twice.
5
+ #
6
+ # `name` stays a bare package specifier: the browser half is served by the
7
+ # client module system, which resolves the Loader entry's package, reads its
8
+ # `dsh.client` manifest, and serves `exports["./client"]`.
9
+ - insert:
10
+ - id: alias
11
+ name: '@maci0/dsh-alias'
12
+ config:
13
+ # name (without the slash) -> the text /<name> expands to. Edit them
14
+ # here, with /alias, or from the Plugins page's Alias card.
15
+ aliases: {}
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M12 18h16" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round"/>
3
+ <path d="M22.5 11.8 28.7 18l-6.2 6.2" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <path d="M7.6 25.4 11.6 10.6" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round" opacity="0.55"/>
5
+ </svg>
package/index.js ADDED
@@ -0,0 +1,487 @@
1
+ /**
2
+ * dsh-alias: user-defined slash commands, as a DeepSeek Harness plugin.
3
+ *
4
+ * `/alias add <name> <text>` registers `/<name>`, `/alias remove <name>` drops
5
+ * it, `/alias list` prints them; a bare `/alias` lists. The same aliases are
6
+ * editable from the Plugins page, where this package's browser half renders the
7
+ * row's configuration card (`lib/client.js`).
8
+ *
9
+ * One settings field carries the state: the `aliases` dictionary of the
10
+ * plugin's own row in the profile patch.
11
+ *
12
+ * ```yaml
13
+ * - id: alias
14
+ * name: '@maci0/dsh-alias'
15
+ * config:
16
+ * aliases: { gm: good morning, summarize the repo }
17
+ * ```
18
+ *
19
+ * The field is `volatile()`, so a write from either surface updates the live
20
+ * config reference in place, with no remount, and emits
21
+ * `loader/volatile-update`; this half re-reads the dictionary there and
22
+ * re-registers exactly the commands that changed. A deployment whose settings
23
+ * document refuses the write still gets a working alias until dsh restarts, held
24
+ * in a local overlay, and the reply says so.
25
+ *
26
+ * What `/<name>` does when invoked:
27
+ *
28
+ * - expanded text starting with `/` runs that command through `ctx.commands`,
29
+ * so `/alias add rev /perf-review` makes `/rev src/app.ts` equivalent to
30
+ * `/perf-review src/app.ts` (bounded to {@link MAX_DEPTH} nested expansions);
31
+ * - anything else is queued to the agent as the ordinary follow-up prompt
32
+ * `/loop`-style, which is what makes `/alias add gm summarize the repo` a
33
+ * usable shortcut.
34
+ *
35
+ * `{args}` in the text is replaced with whatever follows the alias name; when
36
+ * the text has no placeholder and the caller typed arguments, they are
37
+ * appended.
38
+ *
39
+ * This file is plain JavaScript on purpose, like `dsh-loop`: the plugin needs
40
+ * no build step, no bundled runtime, and `bun test` runs the suite on the
41
+ * sources directly.
42
+ *
43
+ * @module dsh-alias
44
+ */
45
+
46
+ import { AsyncLocalStorage } from 'node:async_hooks'
47
+ import { randomUUID } from 'node:crypto'
48
+ import Schema from '@deepseek-ai/schemastery'
49
+
50
+ /** Plugin name as it appears in the loader. */
51
+ export const name = 'alias'
52
+
53
+ /**
54
+ * Row id this plugin's browser card is keyed by: the Plugins page pairs
55
+ * `plugins.row.config` entry `dsh-alias#alias` with the settings namespace the
56
+ * row declares, which is this same id.
57
+ */
58
+ export const ROW_ID = 'alias'
59
+
60
+ /** The names the command registry accepts, and this plugin therefore allows. */
61
+ const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
62
+
63
+ /** A complete slash-command line, split the way the registry splits it. */
64
+ const COMMAND_LINE = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u
65
+
66
+ /** Name of the management command itself: no alias may shadow it. */
67
+ const ALIAS_COMMAND = 'alias'
68
+
69
+ /** How far one alias may expand into another before the call is refused. */
70
+ export const MAX_DEPTH = 4
71
+
72
+ /**
73
+ * Nested-expansion depth of the invocation chain currently running. Per async
74
+ * chain, not per plugin: concurrent invocations (another session, another
75
+ * plugin's nested command) must not count each other's nesting.
76
+ */
77
+ const aliasDepth = new AsyncLocalStorage()
78
+
79
+ /** Longest alias text echoed in a description line or a listing. */
80
+ const PREVIEW_LIMIT = 72
81
+
82
+ /** Usage line shared by every rejected `/alias` form. */
83
+ const USAGE = 'Usage: /alias add <name> <text> (creates /<name>); '
84
+ + '/alias remove <name>; /alias list. A name is lowercase letters, digits, `_` or `-`. '
85
+ + 'Text may use {args} for whatever you type after the alias.'
86
+
87
+ /**
88
+ * Configuration accepted from this plugin's row in a profile patch.
89
+ *
90
+ * `aliases` maps a command name (without the slash) to the text that command
91
+ * expands to. It is the only editable field, it is `volatile()` so both
92
+ * surfaces edit it live, and the `default({})` keeps the form present in a
93
+ * deployment whose row states no aliases.
94
+ *
95
+ * `apply` receives the schema-resolved row, so the field is a live reference
96
+ * rather than a value: read it with `.get()`, as the harness documents for
97
+ * every volatile Config field.
98
+ *
99
+ * @typedef {{ readonly aliases: import('@deepseek-ai/cordis').Volatile<Record<string, string>> }} Config
100
+ */
101
+ export const Config = Schema.object({
102
+ aliases: Schema.dict(Schema.string()).default({}).volatile(),
103
+ })
104
+
105
+ /**
106
+ * Parse the text after `/alias`.
107
+ * @param {string} input - verbatim `rawInput` of the invocation.
108
+ * @returns {{ kind: 'list' }
109
+ * | { kind: 'add', name: string, text: string }
110
+ * | { kind: 'remove', name: string }
111
+ * | { kind: 'error', text: string }} the requested verb.
112
+ */
113
+ export function parseAliasInput(input) {
114
+ const trimmed = input.trim()
115
+ if (trimmed === '') return { kind: 'list' }
116
+ const space = trimmed.search(/\s/u)
117
+ const verb = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase()
118
+ const rest = space === -1 ? '' : trimmed.slice(space + 1).trim()
119
+
120
+ if (verb === 'list') return rest === '' ? { kind: 'list' } : { kind: 'error', text: USAGE }
121
+ if (verb === 'add' || verb === 'set') {
122
+ // The name and the text are separated by whitespace; the text keeps its own.
123
+ const match = /^([a-z][a-z0-9_-]*)\s+([\s\S]+)$/u.exec(rest)
124
+ if (match === null) return { kind: 'error', text: USAGE }
125
+ const text = match[2].trim()
126
+ if (text === '') return { kind: 'error', text: USAGE }
127
+ return { kind: 'add', name: match[1], text }
128
+ }
129
+ if (verb === 'remove' || verb === 'rm' || verb === 'delete') {
130
+ if (!COMMAND_NAME.test(rest)) return { kind: 'error', text: USAGE }
131
+ return { kind: 'remove', name: rest }
132
+ }
133
+ return { kind: 'error', text: USAGE }
134
+ }
135
+
136
+ /**
137
+ * Expand one alias into the line it stands for.
138
+ * @param {string} text - the alias text as configured.
139
+ * @param {string} args - what the caller typed after the alias name, trimmed.
140
+ * @returns {string} the expanded text.
141
+ */
142
+ export function expandAlias(text, args) {
143
+ const expanded = text.includes('{args}') ? text.split('{args}').join(args) : text
144
+ if (text.includes('{args}') || args === '') return expanded
145
+ return `${expanded} ${args}`
146
+ }
147
+
148
+ /**
149
+ * Freeze a freshly built message graph in place.
150
+ * @template T
151
+ * @param {T} value - the value to freeze, children included.
152
+ * @returns {T} the same value.
153
+ */
154
+ function deepFreeze(value) {
155
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
156
+ Object.freeze(value)
157
+ for (const child of Object.values(value)) deepFreeze(child)
158
+ }
159
+ return value
160
+ }
161
+
162
+ /**
163
+ * Build the follow-up prompt one alias expands to.
164
+ *
165
+ * Mirrors `createUserMessage` from `@deepseek-ai/dsh-llm/message` (a frozen,
166
+ * freshly identified plain object) instead of importing the harness package:
167
+ * this plugin then carries no runtime dependency beyond its Config schema, and
168
+ * the suite runs against these sources without the harness installed. The
169
+ * source is deliberately not `user`, so the terse-talk deactivation watchers
170
+ * cannot mistake echoed text for the human's own words.
171
+ * @param {string} text - the prompt the alias stands for.
172
+ * @returns {object} a frozen user message ready for `agent.followup`.
173
+ */
174
+ export function userMessage(text) {
175
+ return deepFreeze({
176
+ id: randomUUID(),
177
+ role: 'user',
178
+ content: [{ type: 'text', text }],
179
+ source: { kind: 'alias', form: 'relay' },
180
+ })
181
+ }
182
+
183
+ /**
184
+ * One-line rendering of an alias text for listings and descriptions.
185
+ * @param {string} text - the alias text as configured.
186
+ * @returns {string} whitespace-collapsed, bounded text.
187
+ */
188
+ export function preview(text) {
189
+ const collapsed = text.replace(/\s+/gu, ' ').trim()
190
+ return collapsed.length <= PREVIEW_LIMIT
191
+ ? collapsed
192
+ : `${collapsed.slice(0, PREVIEW_LIMIT - 1)}…`
193
+ }
194
+
195
+ /**
196
+ * Mount the plugin.
197
+ * @param {object} ctx - the host context.
198
+ * @param {Config} config - the row's resolved configuration.
199
+ */
200
+ export function apply(ctx, config) {
201
+ const warn = (message) => { console.warn(`[alias] ${message}`) }
202
+ const message = (error) => (error instanceof Error ? error.message : String(error))
203
+
204
+ /** Aliases already reported as unusable, so one bad row warns once. */
205
+ const reported = new Set()
206
+
207
+ /**
208
+ * The configured dictionary, validated field by field: a hand-edited row can
209
+ * name a command the registry refuses, and that must cost one warning rather
210
+ * than the whole plugin.
211
+ * @returns {Record<string, string>} name to text, invalid entries dropped.
212
+ */
213
+ const configured = () => {
214
+ const raw = config.aliases.get()
215
+ const aliases = {}
216
+ if (raw === null || typeof raw !== 'object') return aliases
217
+ for (const [aliasName, text] of Object.entries(raw)) {
218
+ if (!COMMAND_NAME.test(aliasName) || aliasName === ALIAS_COMMAND) {
219
+ if (!reported.has(aliasName)) {
220
+ reported.add(aliasName)
221
+ warn(`ignoring alias "${aliasName}": a name must match ${String(COMMAND_NAME)} and must not be "${ALIAS_COMMAND}"`)
222
+ }
223
+ continue
224
+ }
225
+ if (typeof text !== 'string' || text.trim() === '') {
226
+ if (!reported.has(aliasName)) {
227
+ reported.add(aliasName)
228
+ warn(`ignoring alias "${aliasName}": its text must be a non-empty string`)
229
+ }
230
+ continue
231
+ }
232
+ aliases[aliasName] = text.trim()
233
+ }
234
+ return aliases
235
+ }
236
+
237
+ /**
238
+ * Runtime aliases the settings document could not hold. They shadow the
239
+ * document until it carries the same value, so an alias added on a read-only
240
+ * deployment still works in every session until dsh restarts.
241
+ * @type {Map<string, string>}
242
+ */
243
+ const local = new Map()
244
+
245
+ /** @returns {Record<string, string>} the document's aliases overlaid by the runtime's. */
246
+ const effective = () => ({ ...configured(), ...Object.fromEntries(local) })
247
+
248
+ /** The injected `commands` scope, once the service is mounted. */
249
+ let commandScope
250
+ /**
251
+ * Every alias command this instance owns, by name, with the text it was
252
+ * registered for. `dispose` is absent when the registry refused the name
253
+ * (another plugin already owns it), which keeps `sync` from retrying.
254
+ * @type {Map<string, { text: string, dispose?: () => void }>}
255
+ */
256
+ const registered = new Map()
257
+
258
+ const entryId = () => {
259
+ const id = ctx.fiber?.entry?.options?.id
260
+ return typeof id === 'string' ? id : undefined
261
+ }
262
+ const settingsService = () => ctx.get('settings')
263
+
264
+ /**
265
+ * Run one alias command.
266
+ *
267
+ * The nested-expansion depth rides the async context of this call, never a
268
+ * shared variable: one counter for the whole plugin would count an expansion
269
+ * suspended elsewhere in the process as this call's own nesting and refuse a
270
+ * legitimate alias as a cycle.
271
+ * @param {string} aliasName - the alias being invoked.
272
+ * @param {object} invocation - the registry's command invocation.
273
+ * @returns {Promise<object>} the command result the UI renders.
274
+ */
275
+ const invokeAlias = async (aliasName, invocation) => {
276
+ const text = effective()[aliasName]
277
+ if (text === undefined) {
278
+ return { kind: 'error', text: `Alias "/${aliasName}" is no longer defined.` }
279
+ }
280
+ const line = expandAlias(text, invocation.rawInput.trim())
281
+
282
+ if (!line.startsWith('/')) {
283
+ // An ordinary prompt: queued as its own follow-up turn, exactly as if the
284
+ // caller had typed the text.
285
+ invocation.agent.followup(userMessage(line))
286
+ return { kind: 'success', text: `/${aliasName}: ${preview(line)}` }
287
+ }
288
+
289
+ const parsed = COMMAND_LINE.exec(line)
290
+ if (parsed === null || commandScope.commands.find(invocation.agent, parsed[1]) === undefined) {
291
+ return {
292
+ kind: 'error',
293
+ text: `Alias "/${aliasName}" stands for ${preview(line)}, which is not a command this session can run.`,
294
+ }
295
+ }
296
+ const depth = aliasDepth.getStore() ?? 0
297
+ if (depth >= MAX_DEPTH) {
298
+ return {
299
+ kind: 'error',
300
+ text: `Alias "/${aliasName}" expands more than ${MAX_DEPTH} levels deep; check the aliases for a cycle.`,
301
+ }
302
+ }
303
+ try {
304
+ const execution = await aliasDepth.run(
305
+ depth + 1,
306
+ () => commandScope.commands.execute(invocation.agent, line, [], invocation.signal),
307
+ )
308
+ return execution === undefined
309
+ ? { kind: 'error', text: `Alias "/${aliasName}" could not run ${preview(line)}.` }
310
+ : execution.result
311
+ } catch (error) {
312
+ return { kind: 'error', text: message(error) }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Register one alias command.
318
+ * @param {string} aliasName - command name without the slash.
319
+ * @param {string} text - the text it expands to.
320
+ * @returns {boolean} whether the registry accepted the name.
321
+ */
322
+ const registerAlias = (aliasName, text) => {
323
+ try {
324
+ const dispose = commandScope.commands.register({
325
+ name: aliasName,
326
+ description: `↪ ${preview(text)}`,
327
+ input: { hint: '{args}' },
328
+ handler: (invocation) => invokeAlias(aliasName, invocation),
329
+ })
330
+ registered.set(aliasName, { text, dispose })
331
+ return true
332
+ } catch (error) {
333
+ warn(`could not register "/${aliasName}": ${message(error)}`)
334
+ registered.set(aliasName, { text })
335
+ return false
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Make the live registry match {@link effective}: drop commands whose alias
341
+ * is gone or whose text moved, register the rest.
342
+ */
343
+ const sync = () => {
344
+ if (commandScope === undefined) return
345
+ const aliases = effective()
346
+ for (const [aliasName, entry] of [...registered]) {
347
+ if (aliases[aliasName] === entry.text) continue
348
+ entry.dispose?.()
349
+ registered.delete(aliasName)
350
+ }
351
+ for (const [aliasName, text] of Object.entries(aliases)) {
352
+ if (!registered.has(aliasName)) registerAlias(aliasName, text)
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Write one field operation through the settings document.
358
+ * @param {Array<{ op: 'set' | 'unset', path: string[], value?: string }>} ops - the edit.
359
+ * @returns {Promise<boolean>} whether the document accepted the write.
360
+ */
361
+ const persist = async (ops) => {
362
+ const settings = settingsService()
363
+ const id = entryId()
364
+ if (settings === undefined || id === undefined) return false
365
+ try {
366
+ await settings.mutate(id, ops)
367
+ return true
368
+ } catch (error) {
369
+ warn(`settings write refused: ${message(error)}`)
370
+ return false
371
+ }
372
+ }
373
+
374
+ /**
375
+ * `/alias add <name> <text>`.
376
+ * @param {string} aliasName - command name without the slash.
377
+ * @param {string} text - the text it expands to.
378
+ * @returns {Promise<object>} the command result.
379
+ */
380
+ const addAlias = async (aliasName, text) => {
381
+ const previous = effective()[aliasName]
382
+ // The runtime overlay lands first, so the alias works on this turn
383
+ // even when the document refuses the write.
384
+ local.set(aliasName, text)
385
+ sync()
386
+ if (registered.get(aliasName)?.dispose === undefined) {
387
+ local.delete(aliasName)
388
+ sync()
389
+ return { kind: 'error', text: `"/${aliasName}" is already a registered command; pick another name.` }
390
+ }
391
+ if (!await persist([{ op: 'set', path: ['aliases', aliasName], value: text }])) {
392
+ return {
393
+ kind: 'success',
394
+ text: `/${aliasName} is set until dsh restarts: the settings document did not accept the write.`,
395
+ }
396
+ }
397
+ if (local.get(aliasName) === text) local.delete(aliasName) // leave any newer edit intact
398
+ return {
399
+ kind: 'success',
400
+ text: previous === undefined
401
+ ? `/${aliasName} now stands for: ${preview(text)}`
402
+ : `/${aliasName} updated to: ${preview(text)}`,
403
+ }
404
+ }
405
+
406
+ /**
407
+ * `/alias remove <name>`.
408
+ * @param {string} aliasName - command name without the slash.
409
+ * @returns {Promise<object>} the command result.
410
+ */
411
+ const removeAlias = async (aliasName) => {
412
+ if (!Object.hasOwn(effective(), aliasName)) {
413
+ return { kind: 'error', text: `Unknown alias "/${aliasName}".` }
414
+ }
415
+ local.delete(aliasName)
416
+ const persisted = await persist([{ op: 'unset', path: ['aliases', aliasName] }])
417
+ sync()
418
+ if (Object.hasOwn(effective(), aliasName)) {
419
+ return {
420
+ kind: 'error',
421
+ text: local.has(aliasName)
422
+ ? `/${aliasName} changed while it was being removed; the newer alias remains.`
423
+ : persisted
424
+ ? `/${aliasName} comes from the profile's composition layer; remove it there.`
425
+ : `/${aliasName} could not be removed: the settings document did not accept the write.`,
426
+ }
427
+ }
428
+ return {
429
+ kind: 'success',
430
+ text: persisted
431
+ ? `Removed /${aliasName}.`
432
+ : `Removed /${aliasName} until dsh restarts: the settings document did not accept the write.`,
433
+ }
434
+ }
435
+
436
+ /**
437
+ * `/alias list`.
438
+ * @returns {object} the command result.
439
+ */
440
+ const listAliases = () => {
441
+ const aliases = effective()
442
+ const names = Object.keys(aliases).sort()
443
+ if (names.length === 0) {
444
+ return { kind: 'success', text: `No aliases defined. Add one with /alias add <name> <text>.` }
445
+ }
446
+ const lines = names.map((aliasName) => ` /${aliasName} ${preview(aliases[aliasName])}`)
447
+ return { kind: 'success', text: [`Aliases (${names.length}):`, ...lines].join('\n') }
448
+ }
449
+
450
+ /**
451
+ * `/alias …`.
452
+ * @param {object} invocation - the registry's command invocation.
453
+ * @returns {Promise<object>} the command result.
454
+ */
455
+ const handleAlias = async (invocation) => {
456
+ const parsed = parseAliasInput(invocation.rawInput)
457
+ if (parsed.kind === 'error') return { kind: 'error', text: parsed.text }
458
+ if (parsed.kind === 'list') return listAliases()
459
+ if (parsed.kind === 'add') return addAlias(parsed.name, parsed.text)
460
+ return removeAlias(parsed.name)
461
+ }
462
+
463
+ // The document is authoritative once it moves: a runtime alias the
464
+ // write did land is dropped, and everything is re-registered from scratch.
465
+ ctx.on('loader/volatile-update', () => {
466
+ for (const [aliasName, text] of [...local]) {
467
+ if (configured()[aliasName] === text) local.delete(aliasName)
468
+ }
469
+ sync()
470
+ })
471
+
472
+ ctx.inject(['commands'], (scope) => {
473
+ commandScope = scope
474
+ scope.commands.register({
475
+ name: ALIAS_COMMAND,
476
+ description: '⇄ Define slash-command aliases: /alias add <name> <text>, /alias remove <name>, /alias list',
477
+ input: { hint: 'add <name> <text> | remove <name> | list' },
478
+ handler: handleAlias,
479
+ })
480
+ sync()
481
+ scope.effect(() => () => {
482
+ for (const entry of registered.values()) entry.dispose?.()
483
+ registered.clear()
484
+ commandScope = undefined
485
+ }, 'dsh-alias: alias commands')
486
+ })
487
+ }
package/lib/client.js ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * dsh-alias: browser half.
3
+ *
4
+ * One surface: the Alias card on the Plugins page, keyed on the `alias`
5
+ * settings namespace the host half declares as its row id. It lists every
6
+ * defined alias with a Remove control and adds one through a name + text pair;
7
+ * every write goes through `ctx.configForms`, the same settings document
8
+ * `/alias` writes on the host, so the two surfaces cannot disagree.
9
+ *
10
+ * Chrome is a stylesheet, not inline style objects: the module system claims
11
+ * every `<style>` tag a factory appends while it materializes and removes it
12
+ * when the package unloads, so the tags cost nothing to own. All classes are
13
+ * `da-`-prefixed because that sheet lands in the page's own document.
14
+ *
15
+ * This file is plain JavaScript on purpose. The client module system serves a
16
+ * package's `exports["./client"]` artifact as a lazy-CJS factory registered on
17
+ * `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
18
+ * plugin can author it directly instead of reproducing the repository's tsdown
19
+ * client preset. `react` is provided by the module system; nothing else is
20
+ * required here.
21
+ *
22
+ * The card mirrors the shipped plugin cards: the page draws the title, icon,
23
+ * and breadcrumb, and the entry renders the body (`view: 'page'`) or the
24
+ * one-liner under the title (`view: 'summary'`).
25
+ */
26
+
27
+ window.__ModuleLoader__.load({
28
+ id: '@maci0/dsh-alias',
29
+
30
+ factory: (require) => {
31
+ var module = { exports: {} }
32
+ var exports = module.exports
33
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
34
+
35
+ const React = require('react')
36
+
37
+ /** Settings namespace shared with the host half; also this card's slot key. */
38
+ const NAMESPACE = 'alias'
39
+
40
+ /** Locale namespace for this plugin's copy. */
41
+ const LOCALE_NS = 'alias'
42
+
43
+ /** The names the command registry accepts, mirroring the host half. */
44
+ const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
45
+
46
+ /** Every class is `da-`-prefixed: the sheet lands in the page's own document. */
47
+ const CSS = [
48
+ '.da-page{display:flex;flex-direction:column;gap:12px}',
49
+ '.da-group{display:flex;flex-direction:column;gap:8px}',
50
+ '.da-label{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
51
+ '.da-list{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}',
52
+ '.da-item{display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-4)}',
53
+ '.da-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
54
+ '.da-name{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}',
55
+ '.da-text{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;overflow-wrap:anywhere}',
56
+ '.da-row{display:flex;flex-wrap:wrap;gap:8px}',
57
+ '.da-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;box-sizing:border-box}',
58
+ '.da-input:disabled{cursor:default;opacity:.5}',
59
+ '.da-input-name{width:160px;flex:none}',
60
+ '.da-input-text{flex:1;min-width:200px}',
61
+ '.da-button{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
62
+ '.da-button:disabled{cursor:default;opacity:.5}',
63
+ '.da-button-remove{flex:none;padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
64
+ '.da-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
65
+ '.da-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
66
+ ].join('')
67
+
68
+ // Appended while the factory materializes: the module system claims the tag
69
+ // for this package and disposes it on unload. Guarded because the node unit
70
+ // tests evaluate this file without a DOM.
71
+ if (typeof document !== 'undefined') {
72
+ const style = document.createElement('style')
73
+ style.textContent = CSS
74
+ document.head.append(style)
75
+ }
76
+
77
+ /** Plugin version, shown in the card header. Kept in lockstep with package.json. */
78
+ const VERSION = '0.5.2'
79
+
80
+ const en = {
81
+ title: 'Alias',
82
+ summaryEmpty: 'No aliases defined.',
83
+ summary: '{count} alias(es) defined.',
84
+ groupExisting: 'Defined aliases',
85
+ groupNew: 'Add an alias',
86
+ namePlaceholder: 'name',
87
+ textPlaceholder: 'text it stands for ({args} optional)',
88
+ add: 'Add',
89
+ update: 'Update',
90
+ edit: 'Edit',
91
+ remove: 'Remove',
92
+ empty: 'No aliases yet. Add one below, or run /alias add <name> <text>.',
93
+ badName: 'A name is lowercase letters, digits, `_` or `-`, and must start with a letter.',
94
+ emptyText: 'The alias text must not be empty.',
95
+ persists: 'Saved to your profile; /alias lists the same set.',
96
+ readOnly: 'Read-only: this deployment does not persist settings, so changes apply to this page only.',
97
+ refused: 'The settings document refused the change.',
98
+ version: 'v{version}',
99
+ }
100
+
101
+ const zh = {
102
+ title: '别名',
103
+ summaryEmpty: '未定义别名。',
104
+ summary: '已定义 {count} 个别名。',
105
+ groupExisting: '已定义的别名',
106
+ groupNew: '添加别名',
107
+ namePlaceholder: '名称',
108
+ textPlaceholder: '它代表的文本(可用 {args})',
109
+ add: '添加',
110
+ update: '更新',
111
+ edit: '编辑',
112
+ remove: '删除',
113
+ empty: '还没有别名。在下面添加,或运行 /alias add <name> <text>。',
114
+ badName: '名称只能是小写字母、数字、`_` 或 `-`,且必须以字母开头。',
115
+ emptyText: '别名文本不能为空。',
116
+ persists: '已保存到你的配置;/alias 列出同一组别名。',
117
+ readOnly: '只读:此部署不持久化设置,改动仅对本页面生效。',
118
+ refused: '设置文档拒绝了此改动。',
119
+ version: 'v{version}',
120
+ }
121
+
122
+ /**
123
+ * Bind one settings scope to a React subscription.
124
+ * @param scope - the scope bound to the alias settings namespace.
125
+ * @returns a hook reading that scope's current snapshot.
126
+ */
127
+ function useScope(scope) {
128
+ const subscribe = (listener) => scope.subscribe(listener)
129
+ const getSnapshot = () => scope.getSnapshot()
130
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
131
+ }
132
+
133
+ /**
134
+ * Read a snapshot's alias dictionary. A namespace this deployment does not
135
+ * serve reports no dictionary, which the card renders as nothing at all.
136
+ * @param snapshot - the settings scope snapshot.
137
+ * @returns name-to-text, or `undefined` when unreadable.
138
+ */
139
+ function aliasesOf(snapshot) {
140
+ if (snapshot.status !== 'ready') return undefined
141
+ const value = snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
142
+ const aliases = value.aliases
143
+ if (aliases === null || typeof aliases !== 'object' || Array.isArray(aliases)) return {}
144
+ return aliases
145
+ }
146
+
147
+ /**
148
+ * One alias row: the name, its text, and the Edit and Remove controls.
149
+ *
150
+ * Edit loads the row into the form below, because a list whose entries can
151
+ * only be deleted is a list users re-type from scratch.
152
+ */
153
+ function AliasRow(props) {
154
+ const { t, name, text, disabled, onRemove, onEdit } = props
155
+ return React.createElement(
156
+ 'li',
157
+ { className: 'da-item' },
158
+ React.createElement(
159
+ 'div',
160
+ { className: 'da-item-main' },
161
+ React.createElement('span', { className: 'da-name' }, `/${name}`),
162
+ React.createElement('span', { className: 'da-text' }, text),
163
+ ),
164
+ React.createElement(
165
+ 'button',
166
+ {
167
+ type: 'button',
168
+ className: 'da-button da-button-remove',
169
+ disabled,
170
+ onClick: () => { onEdit(name, text) },
171
+ },
172
+ t('edit'),
173
+ ),
174
+ React.createElement(
175
+ 'button',
176
+ {
177
+ type: 'button',
178
+ className: 'da-button da-button-remove',
179
+ disabled,
180
+ onClick: () => { onRemove(name) },
181
+ },
182
+ t('remove'),
183
+ ),
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Build the card component over one bound settings scope.
189
+ * @param scope - the scope bound to the alias settings namespace.
190
+ * @param t - translate function bound to this plugin's locale namespace.
191
+ * @returns the component the slot renders.
192
+ */
193
+ function createCard(scope, t) {
194
+ const useAlias = useScope(scope)
195
+
196
+ return function AliasCard(props) {
197
+ const snapshot = useAlias()
198
+ const [error, setError] = React.useState(null)
199
+ const [draftName, setDraftName] = React.useState('')
200
+ const [draftText, setDraftText] = React.useState('')
201
+ const [pending, setPending] = React.useState(false)
202
+
203
+ const aliases = aliasesOf(snapshot)
204
+ // A namespace this deployment does not serve renders no trace of itself.
205
+ if (aliases === undefined) return null
206
+
207
+ const names = Object.keys(aliases).sort()
208
+ if (props != null && props.view === 'summary') {
209
+ return names.length === 0 ? t('summaryEmpty') : t('summary', { count: String(names.length) })
210
+ }
211
+
212
+ const disabled = !snapshot.writable || pending
213
+ // `mutate` resolves `false` when the host refuses the write (an overlay
214
+ // row, a revision conflict), so a refusal is reported here, not thrown.
215
+ const write = (ops, onAccepted) => {
216
+ if (disabled) return
217
+ setError(null)
218
+ setPending(true)
219
+ new Promise((resolve) => { resolve(scope.mutate(ops)) }).then((accepted) => {
220
+ if (accepted === false) setError(t('refused'))
221
+ else onAccepted?.()
222
+ }).catch((cause) => {
223
+ setError(cause instanceof Error ? cause.message : String(cause))
224
+ }).finally(() => { setPending(false) })
225
+ }
226
+ const edit = (name, text) => {
227
+ setError(null)
228
+ setDraftName(name)
229
+ setDraftText(text)
230
+ }
231
+ const remove = (name) => {
232
+ write([{ op: 'unset', path: ['aliases', name] }])
233
+ }
234
+ const add = () => {
235
+ const name = draftName.trim().toLowerCase()
236
+ const text = draftText.trim()
237
+ if (!COMMAND_NAME.test(name)) {
238
+ setError(t('badName'))
239
+ return
240
+ }
241
+ if (text === '') {
242
+ setError(t('emptyText'))
243
+ return
244
+ }
245
+ write([{ op: 'set', path: ['aliases', name], value: text }], () => {
246
+ setDraftName('')
247
+ setDraftText('')
248
+ })
249
+ }
250
+
251
+ return React.createElement(
252
+ 'div',
253
+ { className: 'da-page' },
254
+ React.createElement(
255
+ 'div',
256
+ { className: 'da-group' },
257
+ React.createElement('span', { className: 'da-label' }, t('groupExisting')),
258
+ names.length === 0
259
+ ? React.createElement('span', { className: 'da-status' }, t('empty'))
260
+ : React.createElement(
261
+ 'ul',
262
+ { className: 'da-list' },
263
+ names.map((name) => React.createElement(AliasRow, {
264
+ key: name, t, name, text: aliases[name], disabled, onRemove: remove, onEdit: edit,
265
+ })),
266
+ ),
267
+ ),
268
+ React.createElement(
269
+ 'div',
270
+ { className: 'da-group' },
271
+ React.createElement('span', { className: 'da-label' }, t('groupNew')),
272
+ React.createElement(
273
+ 'div',
274
+ { className: 'da-row' },
275
+ React.createElement('input', {
276
+ className: 'da-input da-input-name',
277
+ type: 'text',
278
+ value: draftName,
279
+ placeholder: t('namePlaceholder'),
280
+ 'aria-label': t('namePlaceholder'),
281
+ disabled,
282
+ onChange: (event) => { setDraftName(event.target.value) },
283
+ }),
284
+ React.createElement('input', {
285
+ className: 'da-input da-input-text',
286
+ type: 'text',
287
+ value: draftText,
288
+ placeholder: t('textPlaceholder'),
289
+ 'aria-label': t('textPlaceholder'),
290
+ disabled,
291
+ onChange: (event) => { setDraftText(event.target.value) },
292
+ onKeyDown: (event) => {
293
+ if (event.key === 'Enter') add()
294
+ },
295
+ }),
296
+ React.createElement(
297
+ 'button',
298
+ { type: 'button', className: 'da-button', disabled, onClick: add },
299
+ // The same write serves both: `set` on an existing name is an
300
+ // update, and saying so is the whole difference.
301
+ Object.hasOwn(aliases, draftName.trim().toLowerCase()) ? t('update') : t('add'),
302
+ ),
303
+ ),
304
+ ),
305
+ React.createElement(
306
+ 'div',
307
+ { className: 'da-status' },
308
+ snapshot.writable ? t('persists') : t('readOnly'),
309
+ ' ',
310
+ t('version', { version: VERSION }),
311
+ ),
312
+ error === null ? null : React.createElement('div', { className: 'da-error' }, error),
313
+ )
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Mount the browser surface: the Alias card on the Plugins page.
319
+ * @param ctx - the browser plugin context.
320
+ */
321
+ function apply(ctx) {
322
+ const t = ctx.locale.bind(LOCALE_NS)
323
+ ctx.effect(
324
+ () => ctx.locale.register(LOCALE_NS, { en, zh }),
325
+ 'dsh-alias: locale dictionary',
326
+ )
327
+
328
+ const scope = ctx.configForms.get(NAMESPACE)
329
+ const Card = createCard(scope, t)
330
+
331
+ // The owner declares its own slot; injecting waits for it to exist, so
332
+ // this registration does not depend on plugin load order. The card takes
333
+ // no injected props (it closes over its own bound scope), so the entry
334
+ // declares the documented `locale` namespace and no `inject`.
335
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
336
+ name: 'plugins.row.config',
337
+ key: '@maci0/dsh-alias#alias',
338
+ locale: LOCALE_NS,
339
+ }, Card))
340
+ }
341
+
342
+ exports.apply = apply
343
+ exports.inject = ['slots', 'configForms', 'locale']
344
+ return module.exports
345
+ },
346
+ })
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Alias",
4
+ "description": "Define your own slash commands with /alias, or from the Alias card on the Plugins page."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "别名",
4
+ "description": "用 /alias 或 Plugins 页面的「别名」卡片定义自己的斜杠命令。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@maci0/dsh-alias",
3
+ "version": "0.5.2",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
7
+ },
8
+ "type": "module",
9
+ "main": "index.js",
10
+ "description": "/alias add|remove|list for DeepSeek Harness: define your own slash commands, persisted in settings, with a configuration card on the Plugins page.",
11
+ "license": "MIT",
12
+ "exports": {
13
+ ".": "./index.js",
14
+ "./client": {
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ },
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-ui-renderer",
29
+ "@deepseek-ai/dsh-client-locale",
30
+ "@deepseek-ai/dsh-client-ui-settings",
31
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
32
+ ]
33
+ },
34
+ "compatibility": {
35
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
36
+ }
37
+ },
38
+ "files": [
39
+ "icon.svg",
40
+ "locale/*.json",
41
+ "index.js",
42
+ "lib/client.js",
43
+ "cordis.patch.yml",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "scripts": {
48
+ "test": "bun test",
49
+ "test:node": "node --test tests/*.test.*"
50
+ },
51
+ "engines": {
52
+ "node": "^22.19.0 || >=24.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@deepseek-ai/cordis": "4.0.5-alpha.1",
56
+ "@deepseek-ai/cosmokit": "~1.8.5"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/maci0/dsh-alias.git"
61
+ },
62
+ "icon": "./icon.svg",
63
+ "dependencies": {
64
+ "@deepseek-ai/schemastery": "^3.18.4"
65
+ },
66
+ "peerDependencies": {
67
+ "@deepseek-ai/cordis": "4.0.5-alpha.1"
68
+ }
69
+ }