dsh-linux-commander 0.0.0-stage → 1.2.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) 2026 yichenbr1888-svg
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 CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # Linux 指令器(dsh-linux-commander)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 侧边停靠的轻量 Linux 指令工具:输入指令得到通俗中文解释,输入操作需求得到一条可复制的 Linux 指令。本地指令库优先,无法准确处理时自动调用 AI 兜底;绝不执行任何命令。
4
+
5
+ ## 安装(其他电脑)
6
+
7
+ 目标电脑需装有带 Web GUI 的 DSH(桌面版)。本包零依赖、无需构建(client.js 已随包发布)。
8
+
9
+ **方式一:npm(推荐)** — 在目标电脑的 DSH 会话里对代理说:
10
+
11
+ > 安装插件 bundle:dsh-linux-commander
12
+
13
+ **方式二:GitHub(无需 npm 账号)** — 对代理说:
14
+
15
+ > 安装插件 bundle:github:yichenbr1888-svg/linux-commander
16
+
17
+ **方式三:离线拷贝** — 把仓库文件夹放到目标机一个**固定位置**(本地安装是链接而非拷贝,装好后不要删除或移动),对代理说:`安装本地插件 bundle:<文件夹绝对路径>`。
18
+
19
+ 安装后刷新 Web GUI,右侧边缘出现停靠图标即成功。然后配置 AI Key(见下节);不配 Key 时本地指令库照常可用。
20
+
21
+ ## 发布(维护者)
22
+
23
+ ```bash
24
+ npm login # 或 pnpm login
25
+ npm publish # 包名无 scope,默认公开
26
+ ```
27
+
28
+ GitHub 仓库:<https://github.com/yichenbr1888-svg/linux-commander>。发布前务必确认包内不含任何 API Key、接口地址或模型名(本仓库均已移除;这些值只走各机环境变量或 profile 覆盖)。
29
+
30
+ ## 结构
31
+
32
+ ```
33
+ index.js Host 入口:注册 /linux-commander/query 本地 HTTP 路由
34
+ src/core.js 意图识别、指令解释(参数/管道/重定向/危险提醒)、自然语言匹配、本地优先流水线
35
+ src/commands.js 内置 Linux 指令库(指令 + 常用参数 + 危险模式)
36
+ src/nl.js 自然语言 → 指令规则(有序,支持参数提取)
37
+ src/ai.js AI 适配器:请求、响应解析、超时、缓存、去重、错误归一化
38
+ src/client/widget.js 浏览器组件源码(停靠按钮 + 悬浮弹窗)
39
+ build-client.mjs 将 widget + 语言包打包为 client.js
40
+ client.js 生成的浏览器 bundle(注册到 shell.overlay 插槽)
41
+ locale/zh.json|en.json 界面与插件元信息文案
42
+ ```
43
+
44
+ UI、识别、本地查询、AI 请求四层分离。浏览器只负责展示与交互;所有查询经本地回环路由进入 Host,由 Host 决定本地回答还是调用 AI。
45
+
46
+ ## AI 接口配置(仅 Host 端)
47
+
48
+ AI 后端的地址、模型与 Key 全部由每台机器自行配置,**发布包与前端 bundle 不含任何密钥或厂商信息**。适配器兼容 OpenAI 风格的 chat completions 接口(`choices[0].message.content`),也容忍部分厂商的扩展响应形状。
49
+
50
+ 三项配置(环境变量优先于 profile 配置):
51
+
52
+ | 环境变量 | 含义 |
53
+ |---|---|
54
+ | `DSH_LINUX_COMMANDER_ENDPOINT` | OpenAI 兼容的 chat completions 完整 URL |
55
+ | `DSH_LINUX_COMMANDER_MODEL` | 模型 ID |
56
+ | `DSH_LINUX_COMMANDER_API_KEY` | API Key |
57
+
58
+ Windows 用 `setx <变量名> "<值>"` 写入后重启 DSH;macOS/Linux 写入 shell 启动文件后重启 DSH。
59
+
60
+ 或在目标机 profile 的 `cordis.patch.yml` 中覆盖本插件行的配置:
61
+
62
+ ```yaml
63
+ - id: linux-commander
64
+ name: 'dsh-linux-commander'
65
+ config:
66
+ endpoint: '<你的 chat completions URL>'
67
+ model: '<模型 ID>'
68
+ apiKey: '<你的 API Key>'
69
+ timeoutMs: 45000 # 3000–120000;推理类模型可能需 20–30s
70
+ ```
71
+
72
+ - 三项齐全才启用 AI 兜底;缺任何一项时本地功能完全可用,AI 查询返回「AI 接口未配置」。
73
+ - 重复查询命中 24 小时 LRU 缓存(最多 500 条),相同请求并发去重,减少 API 消耗。
74
+
75
+ ## 速度优化
76
+
77
+ - **本地优先**:命中内置指令库的查询约 5ms 返回,完全不调 AI;指令库持续扩充以减少兜底频率。
78
+ - **固定关闭深度思考**:本工具只处理简单的指令解释/生成,不需要深度推理。请求固定携带 `thinking:{type:"disabled"}`,实测同一查询从约 10s 降到约 2.5s。
79
+ - **长缓存**:指令知识稳定,缓存 TTL 24 小时,重复查询约 50ms。
80
+ - 请求使用 `max_completion_tokens`(`max_tokens` 已被部分厂商废弃);不发送 `temperature`(部分服务端固定为 1,传了也无效)。
81
+
82
+ ## 扩展指令库
83
+
84
+ - 新增指令:在 `src/commands.js` 的 `COMMANDS` 中加 `{ d: '中文解释', f: { '-x': '参数说明' }, danger: '可选风险提示' }`。
85
+ - 新增危险模式:在 `DANGER_RULES` 中加 `{ re: '正则字符串', warn: '提示' }`。
86
+ - 新增自然语言规则:在 `src/nl.js` 的 `NL_RULES` 中按「具体在前、泛化在后」插入 `{ re: /…/i, cmd: '…' 或 (m)=>`…`, note: '…', warn: '…' }`。
87
+ - 数据改动只需重新启用 Host 插件;改动 `src/client/widget.js` 或语言包后运行 `node build-client.mjs` 重新生成 client.js。
88
+
89
+ ## 交互
90
+
91
+ - 待机:右侧边缘窄条图标,无文字;点击展开。**按住图标可上下拖动**调整停靠位置(松手自动记忆,窗口缩放自适应);键盘聚焦后 ↑/↓ 微调(Shift 加速)。
92
+ - 展开:弹窗对齐停靠位置并自动避开屏幕边缘;输入框自动聚焦,Enter 发送,Esc 或右上角按钮收起。
93
+ - 结果:等宽字体命令 + 一键复制(成功/失败短暂反馈)+ 一句解释 + 可选风险提醒;右下角小标签标注「本地 / AI」来源。
94
+ - 加载中为三点动画;新查询自动替换旧结果并中止旧请求;重复 Enter 不重复发请求。
95
+ - 浅色/深色跟随 Harness 主题(仅使用 --dsw-alias-* 主题令牌),适配移动端与 prefers-reduced-motion。
96
+
97
+ ## 安全
98
+
99
+ - 只解释/生成文本,绝不执行命令;用户输入不会作为脚本运行。
100
+ - HTTP 路由仅绑定回环地址,不设置 CORS 头,外部站点无法读取响应。
101
+ - 请求体限制 16KB;AI 超时、鉴权失败、限流、格式异常均返回简短错误,不影响本地查询。
@@ -0,0 +1,59 @@
1
+ // Builds client.js (the browser bundle) from src/client/widget.js and the
2
+ // locale dictionaries. Run with Node after editing the widget or locales:
3
+ // node build-client.mjs
4
+ // The command library lives on the host only; editing src/commands.js or
5
+ // src/nl.js does NOT require a client rebuild (restart the host plugin).
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ const root = dirname(fileURLToPath(import.meta.url));
11
+ // The factory id MUST equal the package name: the host builds the client boot
12
+ // graph row from package.json "name", and the browser matches the registered
13
+ // factory to that row by id. A stale/hardcoded id makes the row fall back to a
14
+ // second bundle execution, which trips the duplicate-factory guard and crashes
15
+ // web boot. Derive it from the manifest so a rename can never drift again.
16
+ const pkgName = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name;
17
+ const zh = JSON.parse(readFileSync(join(root, 'locale', 'zh.json'), 'utf8'));
18
+ const en = JSON.parse(readFileSync(join(root, 'locale', 'en.json'), 'utf8'));
19
+ const widget = readFileSync(join(root, 'src', 'client', 'widget.js'), 'utf8');
20
+
21
+ const out = `// Generated by build-client.mjs — do not edit by hand.
22
+ window.__ModuleLoader__.load({
23
+ id: ${JSON.stringify(pkgName)},
24
+ factory(require) {
25
+ const React = require('react');
26
+ const DICT_ZH = ${JSON.stringify(zh.ui)};
27
+ const DICT_EN = ${JSON.stringify(en.ui)};
28
+ ${widget}
29
+ return {
30
+ inject: ['slots', 'locale'],
31
+ apply(ctx) {
32
+ const t = ctx.locale.bind('linuxCommander');
33
+ ctx.effect(() => {
34
+ const disposers = [];
35
+ for (const [loc, dict] of [['zh', DICT_ZH], ['en', DICT_EN]]) {
36
+ try { disposers.push(ctx.locale.register('linuxCommander', loc, dict)); }
37
+ catch (err) { console.error('linux-commander locale register:', err); }
38
+ }
39
+ return () => { for (const d of disposers) { try { d(); } catch { /* already gone */ } } };
40
+ }, 'linux-commander.locale');
41
+ const Widget = createWidget(React, ctx, t);
42
+ ctx.effect(() => {
43
+ const el = document.createElement('style');
44
+ el.setAttribute('data-plugin', ${JSON.stringify(pkgName)});
45
+ el.textContent = CSS;
46
+ document.head.appendChild(el);
47
+ return () => el.remove();
48
+ }, 'linux-commander.styles');
49
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
50
+ name: 'shell.overlay', id: 'linux-commander', order: 60,
51
+ }, Widget));
52
+ },
53
+ };
54
+ },
55
+ });
56
+ `;
57
+
58
+ writeFileSync(join(root, 'client.js'), out);
59
+ console.log(`client.js written: ${out.length} bytes`);
package/client.js ADDED
@@ -0,0 +1,427 @@
1
+ // Generated by build-client.mjs — do not edit by hand.
2
+ window.__ModuleLoader__.load({
3
+ id: "dsh-linux-commander",
4
+ factory(require) {
5
+ const React = require('react');
6
+ const DICT_ZH = {"title":"Linux 指令器","placeholder":"输入指令或操作","open":"打开 Linux 指令器","close":"收起","copy":"复制","copied":"已复制","copyFailed":"复制失败","loading":"查询中","sourceLocal":"本地","sourceAi":"AI","errorGeneric":"查询失败,请稍后重试","errorNetwork":"无法连接本地服务","emptyInput":"请输入内容"};
7
+ const DICT_EN = {"title":"Linux commands","placeholder":"Command or task","open":"Open Linux commands","close":"Collapse","copy":"Copy","copied":"Copied","copyFailed":"Copy failed","loading":"Searching","sourceLocal":"local","sourceAi":"AI","errorGeneric":"Query failed, please retry","errorNetwork":"Cannot reach the local service","emptyInput":"Type something first"};
8
+ // Linux 指令器 — client widget source (plain JS, inlined into client.js by
9
+ // build-client.mjs). Expects React, ctx, t in scope via createWidget args.
10
+ // Minimal floating tool: right-edge dock tab -> compact query panel.
11
+ // Never executes anything; commands are text only.
12
+
13
+ const CSS = String.raw`
14
+ .lxc-root{position:fixed;right:0;top:0;width:0;height:0;z-index:1000;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;}
15
+ .lxc-root *,.lxc-root *::before,.lxc-root *::after{box-sizing:border-box;}
16
+ .lxc-dock{position:absolute;right:0;margin-top:-28px;width:30px;height:56px;display:flex;align-items:center;justify-content:center;padding:0;border:1px solid var(--dsw-alias-border-l1);border-right:none;border-radius:10px 0 0 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;box-shadow:-2px 2px 10px color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent);transition:color .16s ease,background .16s ease,transform .16s ease,box-shadow .16s ease;animation:lxc-fade .18s ease;}
17
+ .lxc-dock:hover{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);transform:translateX(-2px);}
18
+ .lxc-dock:active{transform:translateX(-2px) scale(.96);}
19
+ .lxc-dock.lxc-dragging{cursor:grabbing;transition:none;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);transform:translateX(-2px) scale(1.04);box-shadow:-4px 5px 16px color-mix(in srgb,var(--dsw-alias-label-primary) 20%,transparent);}
20
+ .lxc-dock:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}
21
+ .lxc-panel{position:absolute;right:10px;width:min(340px,calc(100vw - 24px));max-height:min(460px,calc(100vh - 32px));display:flex;flex-direction:column;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);border-radius:14px;box-shadow:0 12px 32px color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent),0 2px 8px color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);animation:lxc-in .19s cubic-bezier(.2,.7,.3,1);}
22
+ .lxc-panel.lxc-closing{animation:lxc-out .15s ease forwards;}
23
+ .lxc-head{display:flex;align-items:center;justify-content:space-between;padding:10px 10px 8px 14px;flex:none;}
24
+ .lxc-title{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-primary);user-select:none;}
25
+ .lxc-close{width:24px;height:24px;display:flex;align-items:center;justify-content:center;padding:0;border:none;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:background .14s ease,color .14s ease;}
26
+ .lxc-close:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}
27
+ .lxc-close:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}
28
+ .lxc-form{padding:0 12px;flex:none;}
29
+ .lxc-input{width:100%;height:36px;padding:0 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit;outline:none;transition:border-color .14s ease,box-shadow .14s ease;}
30
+ .lxc-input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.75;}
31
+ .lxc-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent);}
32
+ .lxc-input.lxc-shake{animation:lxc-shake .24s ease;}
33
+ .lxc-result{margin:10px 12px 12px;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2) transparent;}
34
+ .lxc-result::-webkit-scrollbar{width:6px;}
35
+ .lxc-result::-webkit-scrollbar-track{background:transparent;}
36
+ .lxc-result::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2);border-radius:3px;}
37
+ .lxc-result::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb,var(--dsw-alias-label-secondary) 55%,transparent);}
38
+ .lxc-cmd{position:relative;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-base);}
39
+ .lxc-cmd code{display:block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all;user-select:text;}
40
+ .lxc-foot{display:flex;align-items:center;justify-content:space-between;margin-top:7px;}
41
+ .lxc-copy{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-family:inherit;cursor:pointer;transition:color .14s ease,border-color .14s ease,background .14s ease;}
42
+ .lxc-copy:hover{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);}
43
+ .lxc-copy:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}
44
+ .lxc-copy.lxc-ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}
45
+ .lxc-copy.lxc-fail{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);}
46
+ .lxc-src{font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.8;user-select:none;letter-spacing:.04em;}
47
+ .lxc-note{margin-top:8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);}
48
+ .lxc-warn{display:flex;align-items:flex-start;gap:6px;margin-top:8px;padding:6px 8px;border-radius:7px;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);color:var(--dsw-alias-state-warn-primary);font-size:11.5px;line-height:1.5;}
49
+ .lxc-warn svg{flex:none;margin-top:1px;}
50
+ .lxc-err{display:flex;align-items:center;gap:6px;padding:6px 2px;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5;}
51
+ .lxc-loading{display:flex;align-items:center;justify-content:center;gap:5px;height:44px;}
52
+ .lxc-loading i{width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-label-secondary);opacity:.35;animation:lxc-pulse 1s ease-in-out infinite;}
53
+ .lxc-loading i:nth-child(2){animation-delay:.16s;}
54
+ .lxc-loading i:nth-child(3){animation-delay:.32s;}
55
+ @keyframes lxc-in{from{opacity:0;transform:translateX(12px);}to{opacity:1;transform:translateX(0);}}
56
+ @keyframes lxc-out{from{opacity:1;transform:translateX(0);}to{opacity:0;transform:translateX(12px);}}
57
+ @keyframes lxc-fade{from{opacity:0;}to{opacity:1;}}
58
+ @keyframes lxc-pulse{0%,100%{opacity:.25;transform:scale(.85);}50%{opacity:.9;transform:scale(1);}}
59
+ @keyframes lxc-shake{0%,100%{transform:translateX(0);}25%{transform:translateX(-3px);}75%{transform:translateX(3px);}}
60
+ @media (max-width:480px){
61
+ .lxc-panel{right:8px;width:calc(100vw - 16px);}
62
+ .lxc-dock{width:26px;height:48px;margin-top:-24px;}
63
+ }
64
+ @media (prefers-reduced-motion:reduce){
65
+ .lxc-panel,.lxc-dock,.lxc-input,.lxc-copy,.lxc-close,.lxc-loading i{animation:none !important;transition:none !important;}
66
+ .lxc-panel.lxc-closing{opacity:0;}
67
+ }
68
+ `;
69
+
70
+ const svgProps = { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true };
71
+
72
+ function DockIcon() {
73
+ return React.createElement('svg', { ...svgProps, width: 15, height: 15 },
74
+ React.createElement('path', { d: 'M3 4.5 L6.5 8 L3 11.5', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' }),
75
+ React.createElement('line', { x1: 8.2, y1: 11.8, x2: 13, y2: 11.8, stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }));
76
+ }
77
+ function CloseIcon() {
78
+ return React.createElement('svg', { ...svgProps, width: 13, height: 13 },
79
+ React.createElement('path', { d: 'M3.5 3.5 L12.5 12.5 M12.5 3.5 L3.5 12.5', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }));
80
+ }
81
+ function CopyIcon() {
82
+ return React.createElement('svg', { ...svgProps, width: 12, height: 12 },
83
+ React.createElement('rect', { x: 5, y: 5, width: 8.5, height: 8.5, rx: 1.8, stroke: 'currentColor', strokeWidth: 1.4 }),
84
+ React.createElement('path', { d: 'M11 3.2 V2.8 A1.8 1.8 0 0 0 9.2 1 H2.8 A1.8 1.8 0 0 0 1 2.8 V9.2 A1.8 1.8 0 0 0 2.8 11 H3.2', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }));
85
+ }
86
+ function CheckIcon() {
87
+ return React.createElement('svg', { ...svgProps, width: 12, height: 12 },
88
+ React.createElement('path', { d: 'M2.5 8.5 L6 12 L13.5 4', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }));
89
+ }
90
+ function WarnIcon() {
91
+ return React.createElement('svg', { ...svgProps, width: 12, height: 12 },
92
+ React.createElement('path', { d: 'M8 1.8 L15 13.6 H1 Z', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round' }),
93
+ React.createElement('line', { x1: 8, y1: 6.2, x2: 8, y2: 9.2, stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }),
94
+ React.createElement('circle', { cx: 8, cy: 11.4, r: .9, fill: 'currentColor' }));
95
+ }
96
+
97
+ function createWidget(React, ctx, t) {
98
+ const h = React.createElement;
99
+ const { useState, useRef, useEffect, useLayoutEffect, useReducer } = React;
100
+
101
+ const DOCK_MARGIN = 36; // keep the tab fully on-screen
102
+ const STORE_KEY = 'linux-commander.dockY';
103
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
104
+
105
+ function initialDockY() {
106
+ const vh = window.innerHeight || 800;
107
+ let frac = 0.5;
108
+ try {
109
+ const saved = Number(localStorage.getItem(STORE_KEY));
110
+ if (Number.isFinite(saved) && saved > 0 && saved < 1) frac = saved;
111
+ } catch { /* storage unavailable */ }
112
+ return clamp(frac * vh, DOCK_MARGIN, vh - DOCK_MARGIN);
113
+ }
114
+
115
+ function Widget() {
116
+ const [open, setOpen] = useState(false);
117
+ const [closing, setClosing] = useState(false);
118
+ const [value, setValue] = useState('');
119
+ const [phase, setPhase] = useState('idle'); // idle | loading | done | error
120
+ const [result, setResult] = useState(null);
121
+ const [error, setError] = useState('');
122
+ const [copyState, setCopyState] = useState('idle'); // idle | ok | fail
123
+ const [shake, setShake] = useState(false);
124
+ const [dockY, setDockYState] = useState(initialDockY);
125
+ const [vh, setVh] = useState(() => window.innerHeight || 800);
126
+ const [panelH, setPanelH] = useState(180);
127
+ const [dragging, setDragging] = useState(false);
128
+ const [, bump] = useReducer((x) => x + 1, 0);
129
+
130
+ const inputRef = useRef(null);
131
+ const dockRef = useRef(null);
132
+ const panelRef = useRef(null);
133
+ const seqRef = useRef(0);
134
+ const abortRef = useRef(null);
135
+ const lastQueryRef = useRef('');
136
+ const closeTimerRef = useRef(null);
137
+ const copyTimerRef = useRef(null);
138
+ const shakeTimerRef = useRef(null);
139
+ const closingRef = useRef(false);
140
+ const dockYRef = useRef(dockY);
141
+ const dragRef = useRef(null);
142
+ const suppressClickRef = useRef(false);
143
+
144
+ function setDockY(y) {
145
+ dockYRef.current = y;
146
+ setDockYState(y);
147
+ }
148
+
149
+ function persistDockY() {
150
+ try {
151
+ const frac = dockYRef.current / (window.innerHeight || vh || 1);
152
+ localStorage.setItem(STORE_KEY, String(clamp(frac, 0, 1)));
153
+ } catch { /* storage unavailable */ }
154
+ }
155
+
156
+ useEffect(() => ctx.locale.subscribe(bump), [ctx]);
157
+
158
+ useEffect(() => {
159
+ const onResize = () => {
160
+ const hgt = window.innerHeight || 800;
161
+ setVh(hgt);
162
+ setDockY(clamp(dockYRef.current, DOCK_MARGIN, hgt - DOCK_MARGIN));
163
+ };
164
+ window.addEventListener('resize', onResize);
165
+ return () => window.removeEventListener('resize', onResize);
166
+ }, []);
167
+
168
+ // Measure the panel so it stays fully on-screen near the dock position.
169
+ useLayoutEffect(() => {
170
+ if (!open || !panelRef.current) return;
171
+ const el = panelRef.current;
172
+ const measure = () => setPanelH(el.offsetHeight || 180);
173
+ measure();
174
+ if (typeof ResizeObserver === 'undefined') return;
175
+ const ro = new ResizeObserver(measure);
176
+ ro.observe(el);
177
+ return () => ro.disconnect();
178
+ }, [open]);
179
+
180
+ useEffect(() => () => {
181
+ abortRef.current?.abort();
182
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
183
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
184
+ if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
185
+ }, []);
186
+
187
+ useEffect(() => {
188
+ if (!open) return;
189
+ const raf = requestAnimationFrame(() => {
190
+ const el = inputRef.current;
191
+ if (el) { el.focus(); el.select(); }
192
+ });
193
+ const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closePanel(); } };
194
+ document.addEventListener('keydown', onKey, true);
195
+ return () => { cancelAnimationFrame(raf); document.removeEventListener('keydown', onKey, true); };
196
+ }, [open]);
197
+
198
+ // --- vertical drag of the docked tab (pointer + keyboard) ---
199
+ function onDockPointerDown(e) {
200
+ if (e.button !== undefined && e.button !== 0) return;
201
+ dragRef.current = { startY: e.clientY, origY: dockYRef.current, moved: false };
202
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* older browsers */ }
203
+ }
204
+
205
+ function onDockPointerMove(e) {
206
+ const d = dragRef.current;
207
+ if (!d) return;
208
+ const dy = e.clientY - d.startY;
209
+ if (!d.moved && Math.abs(dy) > 4) { d.moved = true; setDragging(true); }
210
+ if (d.moved) {
211
+ e.preventDefault();
212
+ setDockY(clamp(d.origY + dy, DOCK_MARGIN, (window.innerHeight || vh) - DOCK_MARGIN));
213
+ }
214
+ }
215
+
216
+ function onDockPointerUp(e) {
217
+ const d = dragRef.current;
218
+ dragRef.current = null;
219
+ try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* already released */ }
220
+ if (!d) return;
221
+ if (d.moved) {
222
+ setDragging(false);
223
+ persistDockY();
224
+ suppressClickRef.current = true; // the trailing click must not open the panel
225
+ }
226
+ }
227
+
228
+ function onDockPointerCancel() {
229
+ dragRef.current = null;
230
+ setDragging(false);
231
+ }
232
+
233
+ function onDockClick() {
234
+ if (suppressClickRef.current) { suppressClickRef.current = false; return; }
235
+ openPanel();
236
+ }
237
+
238
+ function onDockKeyDown(e) {
239
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
240
+ e.preventDefault();
241
+ const step = e.shiftKey ? 96 : 24;
242
+ const dy = e.key === 'ArrowUp' ? -step : step;
243
+ setDockY(clamp(dockYRef.current + dy, DOCK_MARGIN, (window.innerHeight || vh) - DOCK_MARGIN));
244
+ persistDockY();
245
+ }
246
+
247
+ function openPanel() {
248
+ if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; }
249
+ closingRef.current = false;
250
+ setClosing(false);
251
+ setOpen(true);
252
+ }
253
+
254
+ function closePanel() {
255
+ if (closingRef.current) return;
256
+ closingRef.current = true;
257
+ abortRef.current?.abort();
258
+ seqRef.current++;
259
+ setPhase((p) => (p === 'loading' ? 'idle' : p));
260
+ setClosing(true);
261
+ closeTimerRef.current = setTimeout(() => {
262
+ closeTimerRef.current = null;
263
+ closingRef.current = false;
264
+ setClosing(false);
265
+ setOpen(false);
266
+ requestAnimationFrame(() => dockRef.current?.focus());
267
+ }, 155);
268
+ }
269
+
270
+ function submit(e) {
271
+ e.preventDefault();
272
+ if (e.nativeEvent && e.nativeEvent.repeat) return;
273
+ const q = value.replace(/[\r\n]+/g, ' ').trim();
274
+ if (!q) {
275
+ setShake(true);
276
+ if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
277
+ shakeTimerRef.current = setTimeout(() => setShake(false), 260);
278
+ inputRef.current?.focus();
279
+ return;
280
+ }
281
+ if (phase === 'loading' && q === lastQueryRef.current) return;
282
+ const seq = ++seqRef.current;
283
+ abortRef.current?.abort();
284
+ const controller = new AbortController();
285
+ abortRef.current = controller;
286
+ lastQueryRef.current = q;
287
+ setPhase('loading');
288
+ setResult(null);
289
+ setError('');
290
+ setCopyState('idle');
291
+ fetch('/linux-commander/query', {
292
+ method: 'POST',
293
+ headers: { 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({ input: q }),
295
+ signal: controller.signal,
296
+ })
297
+ .then(async (res) => {
298
+ let data = null;
299
+ try { data = await res.json(); } catch { /* non-JSON */ }
300
+ if (seq !== seqRef.current) return;
301
+ if (data && data.ok) { setResult(data); setPhase('done'); }
302
+ else { setError((data && data.message) || t('errorGeneric')); setPhase('error'); }
303
+ })
304
+ .catch(() => {
305
+ if (seq !== seqRef.current || controller.signal.aborted) return;
306
+ setError(t('errorNetwork'));
307
+ setPhase('error');
308
+ });
309
+ }
310
+
311
+ async function copyCommand() {
312
+ const text = result?.command;
313
+ if (!text) return;
314
+ let ok = false;
315
+ try {
316
+ await navigator.clipboard.writeText(text);
317
+ ok = true;
318
+ } catch {
319
+ try {
320
+ const ta = document.createElement('textarea');
321
+ ta.value = text;
322
+ ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
323
+ document.body.appendChild(ta);
324
+ ta.select();
325
+ ok = document.execCommand('copy');
326
+ ta.remove();
327
+ } catch { ok = false; }
328
+ }
329
+ setCopyState(ok ? 'ok' : 'fail');
330
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
331
+ copyTimerRef.current = setTimeout(() => setCopyState('idle'), 1400);
332
+ }
333
+
334
+ if (!open) {
335
+ return h('div', { className: 'lxc-root' },
336
+ h('button', {
337
+ ref: dockRef, type: 'button',
338
+ className: 'lxc-dock' + (dragging ? ' lxc-dragging' : ''),
339
+ style: { top: dockY + 'px' },
340
+ 'aria-label': t('open'),
341
+ onClick: onDockClick,
342
+ onPointerDown: onDockPointerDown,
343
+ onPointerMove: onDockPointerMove,
344
+ onPointerUp: onDockPointerUp,
345
+ onPointerCancel: onDockPointerCancel,
346
+ onKeyDown: onDockKeyDown,
347
+ }, h(DockIcon)));
348
+ }
349
+
350
+ const panelTop = clamp(dockY - panelH / 2, 8, Math.max(8, vh - panelH - 8));
351
+
352
+ const copyLabel = copyState === 'ok' ? t('copied') : copyState === 'fail' ? t('copyFailed') : t('copy');
353
+ const sourceLabel = result ? (result.source === 'local' ? t('sourceLocal') : t('sourceAi')) : '';
354
+
355
+ let body = null;
356
+ if (phase === 'loading') {
357
+ body = h('div', { className: 'lxc-loading', role: 'status', 'aria-label': t('loading') },
358
+ h('i'), h('i'), h('i'));
359
+ } else if (phase === 'error') {
360
+ body = h('div', { className: 'lxc-err', role: 'alert' }, error);
361
+ } else if (phase === 'done' && result) {
362
+ body = h(React.Fragment, null,
363
+ h('div', { className: 'lxc-cmd' }, h('code', null, result.command)),
364
+ h('div', { className: 'lxc-foot' },
365
+ h('button', {
366
+ type: 'button',
367
+ className: 'lxc-copy' + (copyState === 'ok' ? ' lxc-ok' : copyState === 'fail' ? ' lxc-fail' : ''),
368
+ onClick: copyCommand, 'aria-live': 'polite',
369
+ }, copyState === 'ok' ? h(CheckIcon) : h(CopyIcon), copyLabel),
370
+ sourceLabel ? h('span', { className: 'lxc-src' }, sourceLabel) : null),
371
+ result.explanation ? h('div', { className: 'lxc-note' }, result.explanation) : null,
372
+ result.warning ? h('div', { className: 'lxc-warn' }, h(WarnIcon), h('span', null, result.warning)) : null);
373
+ }
374
+
375
+ return h('div', { className: 'lxc-root' },
376
+ h('div', {
377
+ ref: panelRef,
378
+ className: 'lxc-panel' + (closing ? ' lxc-closing' : ''),
379
+ style: { top: panelTop + 'px' },
380
+ role: 'dialog', 'aria-label': t('title'),
381
+ },
382
+ h('div', { className: 'lxc-head' },
383
+ h('span', { className: 'lxc-title' }, t('title')),
384
+ h('button', { type: 'button', className: 'lxc-close', 'aria-label': t('close'), onClick: closePanel }, h(CloseIcon))),
385
+ h('form', { className: 'lxc-form', onSubmit: submit },
386
+ h('input', {
387
+ ref: inputRef,
388
+ className: 'lxc-input' + (shake ? ' lxc-shake' : ''),
389
+ type: 'text', value, placeholder: t('placeholder'),
390
+ 'aria-label': t('placeholder'),
391
+ autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false,
392
+ enterKeyHint: 'search',
393
+ onChange: (e) => setValue(e.target.value),
394
+ onKeyDown: (e) => { if (e.key === 'Enter' && e.repeat) e.preventDefault(); },
395
+ })),
396
+ h('div', { className: 'lxc-result', 'aria-live': 'polite' }, body)));
397
+ }
398
+
399
+ return Widget;
400
+ }
401
+ return {
402
+ inject: ['slots', 'locale'],
403
+ apply(ctx) {
404
+ const t = ctx.locale.bind('linuxCommander');
405
+ ctx.effect(() => {
406
+ const disposers = [];
407
+ for (const [loc, dict] of [['zh', DICT_ZH], ['en', DICT_EN]]) {
408
+ try { disposers.push(ctx.locale.register('linuxCommander', loc, dict)); }
409
+ catch (err) { console.error('linux-commander locale register:', err); }
410
+ }
411
+ return () => { for (const d of disposers) { try { d(); } catch { /* already gone */ } } };
412
+ }, 'linux-commander.locale');
413
+ const Widget = createWidget(React, ctx, t);
414
+ ctx.effect(() => {
415
+ const el = document.createElement('style');
416
+ el.setAttribute('data-plugin', "dsh-linux-commander");
417
+ el.textContent = CSS;
418
+ document.head.appendChild(el);
419
+ return () => el.remove();
420
+ }, 'linux-commander.styles');
421
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
422
+ name: 'shell.overlay', id: 'linux-commander', order: 60,
423
+ }, Widget));
424
+ },
425
+ };
426
+ },
427
+ });
@@ -0,0 +1,12 @@
1
+ # Linux 指令器 — host row (publish-safe: no secrets or vendor values here).
2
+ # Configure the AI backend per machine via EITHER environment variables:
3
+ # DSH_LINUX_COMMANDER_API_KEY (required for AI fallback)
4
+ # DSH_LINUX_COMMANDER_ENDPOINT (OpenAI-compatible chat completions URL)
5
+ # DSH_LINUX_COMMANDER_MODEL (model id)
6
+ # or a profile-level cordis.patch.yml override of this row's config:
7
+ # config: { apiKey: '...', endpoint: '...', model: '...' }
8
+ - insert:
9
+ - id: linux-commander
10
+ name: 'dsh-linux-commander'
11
+ config:
12
+ timeoutMs: 45000
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="#16202b"/>
3
+ <rect x="4.75" y="4.75" width="54.5" height="54.5" rx="13.25" fill="none" stroke="#2c3a4a" stroke-width="1.5"/>
4
+ <path d="M16 22 L28 32 L16 42" fill="none" stroke="#41d67c" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
5
+ <line x1="33" y1="43" x2="48" y2="43" stroke="#e8eef4" stroke-width="5" stroke-linecap="round"/>
6
+ </svg>