@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 +21 -0
- package/README.md +111 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +5 -0
- package/index.js +487 -0
- package/lib/client.js +346 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -0
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).
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
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
|
+
}
|