acryl-dsh-editor-plugin-cli 0.1.0
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 +59 -0
- package/cordis.patch.yml +20 -0
- package/lib/index.js +256 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 DSH Code Editor contributors
|
|
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,59 @@
|
|
|
1
|
+
# DSH Editor (CLI)
|
|
2
|
+
|
|
3
|
+
A native `/files` command for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)'s
|
|
4
|
+
terminal surface (`acryl-cli`) - browse and view files without leaving the
|
|
5
|
+
TUI.
|
|
6
|
+
|
|
7
|
+
This is the TUI-native sibling of
|
|
8
|
+
[`acryl-dsh-editor-plugin-web`](https://github.com/acryldev/acryl-dsh-editor-plugin-web),
|
|
9
|
+
built for `acryl-cli`'s own presentation-slot extension point
|
|
10
|
+
(`tuiCommands`, spec 034 T009) rather than a browser client bundle. There is
|
|
11
|
+
no Host/Client split here: the CLI and its Cordis Loader tree share one
|
|
12
|
+
process, so this plugin's own `apply(ctx)` both registers the `/files`
|
|
13
|
+
command and reads files directly - no wire protocol to design.
|
|
14
|
+
|
|
15
|
+
## Features (v1)
|
|
16
|
+
|
|
17
|
+
- **File tree** - browse from your home directory, `..` to go up, Enter to
|
|
18
|
+
open a folder or file.
|
|
19
|
+
- **File viewer** - scrollable, read-only, line-numbered.
|
|
20
|
+
|
|
21
|
+
Deliberately scoped down from the Web sibling's full editor: no editing, no
|
|
22
|
+
cross-file search, no git diff, no Markdown rendering. Those are real,
|
|
23
|
+
separate follow-up work once this seam has a second real consumer to
|
|
24
|
+
generalize from.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
dsh plugin --profile <name> add acryl-dsh-editor-plugin-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
(Each profile is its own single-package pnpm workspace, which currently
|
|
33
|
+
needs `-w` to satisfy pnpm's own workspace-root safety check:
|
|
34
|
+
`dsh plugin --profile <name> add acryl-dsh-editor-plugin-cli -w`.)
|
|
35
|
+
|
|
36
|
+
After installing, restart `acryl tui`. Type `/files` at the prompt.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Node.js `>= 20`
|
|
41
|
+
- `acryl-cli` with `tuiCommands` (spec 034 T009 or later) - the extension
|
|
42
|
+
point this plugin registers against. Installing this on a surface without
|
|
43
|
+
it (Web, Desktop) leaves the plugin's row simply inactive, not crashed:
|
|
44
|
+
`tuiCommands` is a hard `inject` requirement, so the Fiber just never
|
|
45
|
+
activates there.
|
|
46
|
+
|
|
47
|
+
## Architecture
|
|
48
|
+
|
|
49
|
+
A DSH TUI plugin declares itself via the npm package's `dsh` field
|
|
50
|
+
(`dsh.bundle.patch` -> `cordis.patch.yml`, which inserts the row) - the same
|
|
51
|
+
convention every DSH plugin uses. Unlike a Web plugin, there is no
|
|
52
|
+
`dsh.client` field: `lib/index.js` is a standard Cordis Host plugin whose
|
|
53
|
+
`apply(ctx)` calls `ctx.tuiCommands.register({command, description, open})`,
|
|
54
|
+
where `open({tui, close})` returns a real `pi-tui` `Component` built and
|
|
55
|
+
owned entirely by this package.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# acryl-dsh-editor-plugin-cli bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# When installed through the DSH plugin CLI:
|
|
4
|
+
#
|
|
5
|
+
# dsh plugin --profile <name> add acryl-dsh-editor-plugin-cli@<version>
|
|
6
|
+
#
|
|
7
|
+
# the package is appended to `dsh.profile.bundles`, and profile boot merges
|
|
8
|
+
# THIS patch (one `insert` of this row) exactly like a manual cordis.patch.yml
|
|
9
|
+
# mount line. No profile file edits needed.
|
|
10
|
+
#
|
|
11
|
+
# There is no browser half to discover here, unlike the Web sibling plugin
|
|
12
|
+
# (acryl-dsh-editor-plugin-web): this row's own `apply()` (lib/index.js)
|
|
13
|
+
# registers a real `/files` TUI command directly against `tuiCommands`
|
|
14
|
+
# (acryl-cli's own presentation-slot extension point, spec 034 T009) - the CLI
|
|
15
|
+
# and its Cordis Loader tree share one process, so there is no client bundle
|
|
16
|
+
# to compose or serve.
|
|
17
|
+
|
|
18
|
+
- insert:
|
|
19
|
+
- id: dsh-editor-cli
|
|
20
|
+
name: 'acryl-dsh-editor-plugin-cli'
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// DSH Editor (CLI) — a TUI-native file browser and viewer.
|
|
2
|
+
//
|
|
3
|
+
// Standard Cordis plugin mounted via cordis.patch.yml (see package.json
|
|
4
|
+
// `dsh.bundle.patch`). Registers a `/files` command with `tuiCommands`
|
|
5
|
+
// (acryl-cli's own TUI presentation-slot extension point, spec 034 T009) -
|
|
6
|
+
// there is no Host/Client split the way the Web sibling plugin
|
|
7
|
+
// (acryl-dsh-editor-plugin-web) needs: the CLI and its Cordis Loader tree
|
|
8
|
+
// share one process, so this file both registers the command AND reads
|
|
9
|
+
// files directly, in-process, with no wire protocol to design.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately v1-scoped: browse and view files (read-only). No editing, no
|
|
12
|
+
// search, no git diff, no Markdown rendering - those are real, separate
|
|
13
|
+
// follow-up work once this seam has a second real consumer to generalize
|
|
14
|
+
// from, not features to guess at up front.
|
|
15
|
+
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, join, sep } from 'node:path'
|
|
19
|
+
|
|
20
|
+
export const name = 'dsh-editor-cli'
|
|
21
|
+
|
|
22
|
+
// tuiCommands is TUI-specific and only ever exists on acryl-cli's own Loader
|
|
23
|
+
// tree - declaring it in the static `inject` list means this plugin's own
|
|
24
|
+
// Fiber simply never activates on a surface that doesn't provide it (Web,
|
|
25
|
+
// Desktop), the same graceful-absence behavior dsh-community-market's own
|
|
26
|
+
// Desktop-only capabilities already rely on, rather than a hard crash.
|
|
27
|
+
export const inject = ['tuiCommands']
|
|
28
|
+
|
|
29
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
30
|
+
const MAX_ENTRIES = 5000
|
|
31
|
+
|
|
32
|
+
/** One directory's sorted, listable entries - directories first, then files, both alphabetical. */
|
|
33
|
+
function listDirectory(path) {
|
|
34
|
+
let names
|
|
35
|
+
try {
|
|
36
|
+
names = readdirSync(path, { withFileTypes: true })
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { error: String((e && e.message) ? e.message : e) }
|
|
39
|
+
}
|
|
40
|
+
const entries = []
|
|
41
|
+
for (const entry of names) {
|
|
42
|
+
if (entries.length >= MAX_ENTRIES) break
|
|
43
|
+
if (entry.name.startsWith('.') && entry.name !== '..') continue
|
|
44
|
+
entries.push({ name: entry.name, isDir: entry.isDirectory() || entry.isSymbolicLink() && isDirLink(path, entry.name) })
|
|
45
|
+
}
|
|
46
|
+
entries.sort((a, b) => {
|
|
47
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
48
|
+
return a.name.localeCompare(b.name)
|
|
49
|
+
})
|
|
50
|
+
if (path !== sep && dirname(path) !== path) entries.unshift({ name: '..', isDir: true })
|
|
51
|
+
return { entries }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isDirLink(path, name) {
|
|
55
|
+
try {
|
|
56
|
+
return statSync(join(path, name)).isDirectory()
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read one file as display lines, capped so a huge or binary file can't hang the render loop. */
|
|
63
|
+
function readFileLines(path) {
|
|
64
|
+
let stat
|
|
65
|
+
try {
|
|
66
|
+
stat = statSync(path)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { error: String((e && e.message) ? e.message : e) }
|
|
69
|
+
}
|
|
70
|
+
if (!stat.isFile()) return { error: 'not a regular file' }
|
|
71
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
72
|
+
return { error: `file is ${Math.round(stat.size / 1024 / 1024)} MB, over the ${Math.round(MAX_FILE_BYTES / 1024 / 1024)} MB display limit` }
|
|
73
|
+
}
|
|
74
|
+
let content
|
|
75
|
+
try {
|
|
76
|
+
content = readFileSync(path, 'utf8')
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return { error: String((e && e.message) ? e.message : e) }
|
|
79
|
+
}
|
|
80
|
+
// A NUL byte is the simplest reliable "this isn't text" signal without a
|
|
81
|
+
// real MIME/content sniff - good enough to avoid rendering garbage.
|
|
82
|
+
if (content.indexOf(String.fromCharCode(0)) !== -1) return { error: 'binary file - not shown' }
|
|
83
|
+
return { lines: content.split(/\r\n|\r|\n/) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const RESET = '\x1b[0m'
|
|
87
|
+
const bold = s => `\x1b[1m${s}${RESET}`
|
|
88
|
+
const dim = s => `\x1b[2m${s}${RESET}`
|
|
89
|
+
const cyanBold = s => `\x1b[1;36m${s}${RESET}`
|
|
90
|
+
|
|
91
|
+
class FilesOverlay {
|
|
92
|
+
constructor(tui, startDir, close) {
|
|
93
|
+
this.tui = tui
|
|
94
|
+
this.close = close
|
|
95
|
+
this.dir = startDir
|
|
96
|
+
this.selected = 0
|
|
97
|
+
this.scrollOffset = 0
|
|
98
|
+
this.mode = 'browse' // 'browse' | 'view'
|
|
99
|
+
this.entries = []
|
|
100
|
+
this.dirError = undefined
|
|
101
|
+
this.viewPath = undefined
|
|
102
|
+
this.viewLines = undefined
|
|
103
|
+
this.viewError = undefined
|
|
104
|
+
this.viewScroll = 0
|
|
105
|
+
this.reload()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
reload() {
|
|
109
|
+
const result = listDirectory(this.dir)
|
|
110
|
+
this.entries = result.entries ?? []
|
|
111
|
+
this.dirError = result.error
|
|
112
|
+
this.selected = Math.min(this.selected, Math.max(0, this.entries.length - 1))
|
|
113
|
+
this.scrollOffset = 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
invalidate() {}
|
|
117
|
+
|
|
118
|
+
listHeight() {
|
|
119
|
+
const availableRows = Math.max(10, this.tui.terminal.rows - 1)
|
|
120
|
+
return Math.max(3, availableRows - 3)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
maxOffset(total, height) {
|
|
124
|
+
return Math.max(0, total - height)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
render(width) {
|
|
128
|
+
if (this.mode === 'view') return this.renderView(width)
|
|
129
|
+
return this.renderBrowse(width)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
renderBrowse(_width) {
|
|
133
|
+
const height = this.listHeight()
|
|
134
|
+
const offset = Math.min(this.scrollOffset, this.maxOffset(this.entries.length, height))
|
|
135
|
+
const lines = [bold(cyanBold(`Files — ${this.dir}`))]
|
|
136
|
+
if (this.dirError !== undefined) {
|
|
137
|
+
lines.push(` ${this.dirError}`)
|
|
138
|
+
lines.push(dim('esc close'))
|
|
139
|
+
return lines
|
|
140
|
+
}
|
|
141
|
+
const windowed = this.entries.slice(offset, offset + height)
|
|
142
|
+
windowed.forEach((entry, i) => {
|
|
143
|
+
const index = offset + i
|
|
144
|
+
const marker = index === this.selected ? '> ' : ' '
|
|
145
|
+
const label = entry.isDir ? `${entry.name}/` : entry.name
|
|
146
|
+
const line = `${marker}${entry.isDir ? bold(label) : label}`
|
|
147
|
+
lines.push(index === this.selected ? cyanBold(line) : line)
|
|
148
|
+
})
|
|
149
|
+
if (this.entries.length === 0) lines.push(dim(' (empty)'))
|
|
150
|
+
lines.push(dim('↑↓ move · enter open · esc close'))
|
|
151
|
+
return lines
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
renderView(_width) {
|
|
155
|
+
const height = this.listHeight()
|
|
156
|
+
const lines = [bold(cyanBold(`File — ${this.viewPath}`))]
|
|
157
|
+
if (this.viewError !== undefined) {
|
|
158
|
+
lines.push(` ${this.viewError}`)
|
|
159
|
+
lines.push(dim('esc back'))
|
|
160
|
+
return lines
|
|
161
|
+
}
|
|
162
|
+
const total = this.viewLines?.length ?? 0
|
|
163
|
+
const offset = Math.min(this.viewScroll, this.maxOffset(total, height))
|
|
164
|
+
const windowed = (this.viewLines ?? []).slice(offset, offset + height)
|
|
165
|
+
const gutterWidth = String(offset + windowed.length).length
|
|
166
|
+
windowed.forEach((line, i) => {
|
|
167
|
+
lines.push(`${dim(String(offset + i + 1).padStart(gutterWidth))} ${line}`)
|
|
168
|
+
})
|
|
169
|
+
lines.push(dim(`↑↓ scroll · esc back (line ${offset + 1}-${offset + windowed.length} of ${total})`))
|
|
170
|
+
return lines
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
handleInput(data) {
|
|
174
|
+
if (this.mode === 'view') {
|
|
175
|
+
this.handleViewInput(data)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
this.handleBrowseInput(data)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
handleBrowseInput(data) {
|
|
182
|
+
if (data === '\x1b' || data === 'q') {
|
|
183
|
+
this.close()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const height = this.listHeight()
|
|
187
|
+
if (data === '\x1b[A') { // up
|
|
188
|
+
this.selected = Math.max(0, this.selected - 1)
|
|
189
|
+
if (this.selected < this.scrollOffset) this.scrollOffset = this.selected
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (data === '\x1b[B') { // down
|
|
193
|
+
this.selected = Math.min(Math.max(0, this.entries.length - 1), this.selected + 1)
|
|
194
|
+
if (this.selected >= this.scrollOffset + height) this.scrollOffset = this.selected - height + 1
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (data === '\r' || data === '\n') {
|
|
198
|
+
const entry = this.entries[this.selected]
|
|
199
|
+
if (entry === undefined) return
|
|
200
|
+
if (entry.name === '..') {
|
|
201
|
+
this.dir = dirname(this.dir)
|
|
202
|
+
this.reload()
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const path = join(this.dir, entry.name)
|
|
206
|
+
if (entry.isDir) {
|
|
207
|
+
this.dir = path
|
|
208
|
+
this.reload()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
this.openFile(path)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
openFile(path) {
|
|
216
|
+
const result = readFileLines(path)
|
|
217
|
+
this.viewPath = path
|
|
218
|
+
this.viewLines = result.lines
|
|
219
|
+
this.viewError = result.error
|
|
220
|
+
this.viewScroll = 0
|
|
221
|
+
this.mode = 'view'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
handleViewInput(data) {
|
|
225
|
+
if (data === '\x1b') {
|
|
226
|
+
this.mode = 'browse'
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const height = this.listHeight()
|
|
230
|
+
const total = this.viewLines?.length ?? 0
|
|
231
|
+
if (data === '\x1b[A') {
|
|
232
|
+
this.viewScroll = Math.max(0, this.viewScroll - 1)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
if (data === '\x1b[B') {
|
|
236
|
+
this.viewScroll = Math.min(this.maxOffset(total, height), this.viewScroll + 1)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (data === '\x1b[5~') { // page up
|
|
240
|
+
this.viewScroll = Math.max(0, this.viewScroll - height)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (data === '\x1b[6~') { // page down
|
|
244
|
+
this.viewScroll = Math.min(this.maxOffset(total, height), this.viewScroll + height)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function apply(ctx) {
|
|
250
|
+
const dispose = ctx.tuiCommands.register({
|
|
251
|
+
command: '/files',
|
|
252
|
+
description: 'Browse and view files',
|
|
253
|
+
open: ({ tui, close }) => new FilesOverlay(tui, homedir(), close),
|
|
254
|
+
})
|
|
255
|
+
ctx.effect(() => dispose, 'dsh-editor-cli: /files command')
|
|
256
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-dsh-editor-plugin-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH TUI plugin: a native /files command - browse and view files from acryl-cli's own terminal UI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/acryldev/acryl-dsh-editor-plugin-cli.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"default": "./lib/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"acryl-package",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"dsh",
|
|
27
|
+
"editor",
|
|
28
|
+
"tui",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"acryl": {
|
|
32
|
+
"schemaVersion": 1,
|
|
33
|
+
"artifacts": {
|
|
34
|
+
"plugins": [
|
|
35
|
+
"./lib/index.js"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"surfaces": ["tui"]
|
|
39
|
+
},
|
|
40
|
+
"dsh": {
|
|
41
|
+
"bundle": {
|
|
42
|
+
"patch": "./cordis.patch.yml"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"lib/index.js",
|
|
47
|
+
"cordis.patch.yml",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|