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 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)
@@ -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
+ }