decorated-pi 0.2.1 → 0.2.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/README.md +25 -1
- package/extensions/index.ts +10 -6
- package/extensions/providers/qianfan-coding.ts +1 -1
- package/extensions/{safety.ts → safety/detect.ts} +36 -176
- package/extensions/safety/index.ts +155 -0
- package/extensions/settings.ts +31 -0
- package/extensions/slash.ts +107 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ Supported languages:
|
|
|
63
63
|
|
|
64
64
|
### 4. Auxiliary Models (Image + Compact)
|
|
65
65
|
|
|
66
|
-
Uses cheaper models for auxiliary tasks, configured via `/
|
|
66
|
+
Uses cheaper models for auxiliary tasks, configured via `/dp-model`:
|
|
67
67
|
|
|
68
68
|
- **Image read fallback** — when the model reads an image file, detects type via magic bytes, calls a configured vision-capable model, and replaces the read result with image analysis text (jpeg, png, gif, webp)
|
|
69
69
|
- **Compact model** — uses a configured model for context compaction (instead of the main model), auto-resumes after compaction.
|
|
@@ -104,6 +104,30 @@ Runtime settings are stored in:
|
|
|
104
104
|
~/.pi/agent/decorated-pi.json
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
### Module Loading
|
|
108
|
+
|
|
109
|
+
Modules can be toggled on/off. Changes take effect after `/reload`.
|
|
110
|
+
|
|
111
|
+
| Module | Default | Effect when disabled |
|
|
112
|
+
| -------- | --------- | --------------------- |
|
|
113
|
+
| `safety` | `true` | No command guard, no protected path check, no secret redaction |
|
|
114
|
+
| `lsp` | `true` | All `lsp_*` tools unregistered — no diagnostics, hover, etc. |
|
|
115
|
+
| `smart-at` | `true` | Fallback to Pi's built-in `@` file completion |
|
|
116
|
+
|
|
117
|
+
Use `/dp-settings` to toggle, or edit the config file directly:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"modules": {
|
|
122
|
+
"safety": true,
|
|
123
|
+
"lsp": false,
|
|
124
|
+
"smart-at": true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Omitted keys default to `true` (enabled).
|
|
130
|
+
|
|
107
131
|
## License
|
|
108
132
|
|
|
109
133
|
MIT
|
package/extensions/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { setupSafety } from "./safety";
|
|
6
|
+
import { setupSafety } from "./safety/index.js";
|
|
7
7
|
import { setupExtendModel } from "./extend-model";
|
|
8
8
|
import { setupSlash } from "./slash";
|
|
9
9
|
import { setupSubdirAgents } from "./subdir-agents";
|
|
@@ -12,15 +12,19 @@ import { setupGuidance } from "./guidance";
|
|
|
12
12
|
import { setupLsp } from "./lsp/index";
|
|
13
13
|
import { setupProviders } from "./providers/index";
|
|
14
14
|
import { setupSmartAt } from "./smart-at";
|
|
15
|
+
import { isModuleEnabled } from "./settings";
|
|
15
16
|
|
|
16
17
|
export default function (pi: ExtensionAPI) {
|
|
17
|
-
|
|
18
|
-
setupExtendModel(pi);
|
|
18
|
+
// Always loaded — core commands and providers
|
|
19
19
|
setupSlash(pi);
|
|
20
|
+
setupProviders(pi);
|
|
21
|
+
setupExtendModel(pi);
|
|
20
22
|
setupSubdirAgents(pi);
|
|
21
23
|
setupSessionTitle(pi);
|
|
22
24
|
setupGuidance(pi);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
|
|
26
|
+
// Configurable modules
|
|
27
|
+
if (isModuleEnabled("safety")) setupSafety(pi);
|
|
28
|
+
if (isModuleEnabled("lsp")) setupLsp(pi);
|
|
29
|
+
if (isModuleEnabled("smart-at")) setupSmartAt(pi);
|
|
26
30
|
}
|
|
@@ -25,7 +25,7 @@ const MODELS: ProviderModelConfig[] = [
|
|
|
25
25
|
{ id: "glm-4.7", name: "GLM 4.7", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
26
26
|
{ id: "glm-5", name: "GLM 5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
27
27
|
{ id: "glm-5.1", name: "GLM 5.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
28
|
-
{ id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow:
|
|
28
|
+
{ id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 229_376, maxTokens: 262_144, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
29
29
|
{ id: "minimax-m2.1", name: "MiniMax M2.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204_800, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
30
30
|
{ id: "minimax-m2.5", name: "MiniMax M2.5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204_800, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
31
31
|
{ id: "ernie-4.5-turbo-20260402", name: "ERNIE 4.5 Turbo", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 12_288, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Safety —
|
|
2
|
+
* Safety Detection — 纯逻辑层(零 Pi 依赖)
|
|
3
3
|
*
|
|
4
|
-
* - Command Guard:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - Write Guard: 覆盖非空文件禁止 write 工具,建议用 edit
|
|
9
|
-
* - Secret Redact: API Key / Token 自动掩码
|
|
4
|
+
* - Command Guard: 危险命令检测 + 覆盖写入检测 + 读取保护路径检测
|
|
5
|
+
* - Secret Detection: 40+ 高置信模式 + 熵分析 V3+Dict + 安全模式排除
|
|
6
|
+
*
|
|
7
|
+
* 本模块可独立测试,不依赖 Pi API。
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
|
-
import type {
|
|
13
|
-
ExtensionAPI,
|
|
14
|
-
ExtensionContext,
|
|
15
|
-
ToolResultEvent,
|
|
16
|
-
} from "@earendil-works/pi-coding-agent";
|
|
17
10
|
import * as fs from "node:fs";
|
|
18
11
|
import { resolve } from "node:path";
|
|
19
12
|
|
|
20
|
-
// ─── 危险命令枚举 ──────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
13
|
const DANGEROUS_COMMANDS: [string, string[]][] = [
|
|
23
14
|
["rm", []],
|
|
24
15
|
["sudo", []],
|
|
@@ -56,7 +47,7 @@ const READ_COMMANDS = new Set([
|
|
|
56
47
|
"file", "strings", "grep", "rg", "ag", "ack",
|
|
57
48
|
]);
|
|
58
49
|
|
|
59
|
-
function checkProtectedPath(filePath: string): string | null {
|
|
50
|
+
export function checkProtectedPath(filePath: string): string | null {
|
|
60
51
|
const normalized = filePath.replace(/\\/g, "/");
|
|
61
52
|
const filename = normalized.split("/").pop() ?? "";
|
|
62
53
|
for (const seg of PROTECTED_PATH_SEGMENTS) {
|
|
@@ -73,7 +64,7 @@ function checkProtectedPath(filePath: string): string | null {
|
|
|
73
64
|
|
|
74
65
|
// ─── Shell tokenizer ────────────────────────────────────────────────────────
|
|
75
66
|
|
|
76
|
-
function tokenizeShell(command: string): string[] {
|
|
67
|
+
export function tokenizeShell(command: string): string[] {
|
|
77
68
|
const tokens: string[] = [];
|
|
78
69
|
let current = "";
|
|
79
70
|
let quote: "'" | '"' | null = null;
|
|
@@ -164,13 +155,13 @@ function isExistingRegularFile(target: string, cwd: string): boolean {
|
|
|
164
155
|
|
|
165
156
|
// ─── Bash danger analysis ───────────────────────────────────────────────────
|
|
166
157
|
|
|
167
|
-
interface BashDanger {
|
|
158
|
+
export interface BashDanger {
|
|
168
159
|
reason: string;
|
|
169
160
|
/** Whether the danger involves a protected (sensitive) path */
|
|
170
161
|
protectedPath?: string;
|
|
171
162
|
}
|
|
172
163
|
|
|
173
|
-
function collectBashDangers(command: string, cwd: string): BashDanger[] {
|
|
164
|
+
export function collectBashDangers(command: string, cwd: string): BashDanger[] {
|
|
174
165
|
const tokens = tokenizeShell(command);
|
|
175
166
|
const dangers: BashDanger[] = [];
|
|
176
167
|
const seen = new Set<string>();
|
|
@@ -261,7 +252,7 @@ function collectBashDangers(command: string, cwd: string): BashDanger[] {
|
|
|
261
252
|
return dangers;
|
|
262
253
|
}
|
|
263
254
|
|
|
264
|
-
function formatBashDangers(dangers: BashDanger[]): string | null {
|
|
255
|
+
export function formatBashDangers(dangers: BashDanger[]): string | null {
|
|
265
256
|
if (dangers.length === 0) return null;
|
|
266
257
|
if (dangers.length === 1) return dangers[0]!.reason;
|
|
267
258
|
return `dangerous operations detected:\n- ${dangers.map(d => d.reason).join("\n- ")}`;
|
|
@@ -290,9 +281,6 @@ function formatBashDangers(dangers: BashDanger[]): string | null {
|
|
|
290
281
|
// - dictRatio: dictionary word coverage penalizes identifiers/English text
|
|
291
282
|
// - hexPenalty: -2.5 only if >90% hex AND contains '-' (UUID-like format)
|
|
292
283
|
|
|
293
|
-
type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
|
|
294
|
-
|
|
295
|
-
// ── Entropy Analysis v3+Dict ─────────────────────────────────────────────────
|
|
296
284
|
//
|
|
297
285
|
// Based on opencode-secrets-protect by Jared Scheel
|
|
298
286
|
// https://github.com/jscheel/opencode-secrets-protect (MIT License)
|
|
@@ -312,7 +300,7 @@ type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number],
|
|
|
312
300
|
// 6. hexPenalty: -2.5 only if >90% hex AND contains '-' (UUID-like format)
|
|
313
301
|
|
|
314
302
|
/** Character class: U=uppercase, L=lowercase, D=digit, S=dash, X=other */
|
|
315
|
-
function charClass(c: string): "U" | "L" | "D" | "S" | "X" {
|
|
303
|
+
export function charClass(c: string): "U" | "L" | "D" | "S" | "X" {
|
|
316
304
|
const code = c.charCodeAt(0);
|
|
317
305
|
if (code >= 65 && code <= 90) return "U";
|
|
318
306
|
if (code >= 97 && code <= 122) return "L";
|
|
@@ -325,7 +313,7 @@ function charClass(c: string): "U" | "L" | "D" | "S" | "X" {
|
|
|
325
313
|
* Shannon entropy: measures average information content per character.
|
|
326
314
|
* H(X) = -Σ p(x) · log₂(p(x))
|
|
327
315
|
*/
|
|
328
|
-
function shannonEntropy(data: string): number {
|
|
316
|
+
export function shannonEntropy(data: string): number {
|
|
329
317
|
if (data.length === 0) return 0;
|
|
330
318
|
const freq = new Map<string, number>();
|
|
331
319
|
for (const char of data) {
|
|
@@ -349,7 +337,7 @@ function shannonEntropy(data: string): number {
|
|
|
349
337
|
* - Case switch AbA pattern (≥2 uppercase + ≥1 lowercase) → 0.8
|
|
350
338
|
* - Otherwise → 0
|
|
351
339
|
*/
|
|
352
|
-
function trigramScore(c1: string, c2: string, c3: string): number {
|
|
340
|
+
export function trigramScore(c1: string, c2: string, c3: string): number {
|
|
353
341
|
const cls: string[] = [charClass(c1), charClass(c2), charClass(c3)];
|
|
354
342
|
|
|
355
343
|
// Any X-class character → skip
|
|
@@ -380,7 +368,7 @@ function trigramScore(c1: string, c2: string, c3: string): number {
|
|
|
380
368
|
* Split a token by X-class characters into independent segments.
|
|
381
369
|
* This prevents `://`, `@`, `.` etc. from diluting trigram density.
|
|
382
370
|
*/
|
|
383
|
-
function splitByXClass(token: string): string[] {
|
|
371
|
+
export function splitByXClass(token: string): string[] {
|
|
384
372
|
const segments: string[] = [];
|
|
385
373
|
let current = "";
|
|
386
374
|
for (const c of token) {
|
|
@@ -398,7 +386,7 @@ function splitByXClass(token: string): string[] {
|
|
|
398
386
|
/**
|
|
399
387
|
* Compute average trigram density for a single segment.
|
|
400
388
|
*/
|
|
401
|
-
function segmentDensity(segment: string): number {
|
|
389
|
+
export function segmentDensity(segment: string): number {
|
|
402
390
|
if (segment.length < 3) return 0;
|
|
403
391
|
let totalScore = 0;
|
|
404
392
|
for (let i = 0; i <= segment.length - 3; i++) {
|
|
@@ -411,7 +399,7 @@ function segmentDensity(segment: string): number {
|
|
|
411
399
|
* Compute the maximum segment density across all X-split segments.
|
|
412
400
|
* The segment with the highest density is the most likely secret region.
|
|
413
401
|
*/
|
|
414
|
-
function maxSegmentDensity(token: string): number {
|
|
402
|
+
export function maxSegmentDensity(token: string): number {
|
|
415
403
|
const segments = splitByXClass(token);
|
|
416
404
|
if (segments.length === 0) return 0;
|
|
417
405
|
let maxD = 0;
|
|
@@ -427,7 +415,7 @@ function maxSegmentDensity(token: string): number {
|
|
|
427
415
|
* lowercase fragments ≥3 characters. Natural language words reduce
|
|
428
416
|
* the likelihood of being a secret.
|
|
429
417
|
*/
|
|
430
|
-
function computeWordRatio(token: string): number {
|
|
418
|
+
export function computeWordRatio(token: string): number {
|
|
431
419
|
// Split by class boundaries
|
|
432
420
|
const segments: string[] = [];
|
|
433
421
|
let current = "";
|
|
@@ -461,7 +449,7 @@ function computeWordRatio(token: string): number {
|
|
|
461
449
|
* Hex ratio: fraction of characters that are hex characters (0-9, a-f, A-F, -).
|
|
462
450
|
* Values >0.9 indicate UUIDs or hex hashes which are safe.
|
|
463
451
|
*/
|
|
464
|
-
function computeHexRatio(token: string): number {
|
|
452
|
+
export function computeHexRatio(token: string): number {
|
|
465
453
|
let hexChars = 0;
|
|
466
454
|
for (const c of token) {
|
|
467
455
|
if (/[0-9a-fA-F\-]/.test(c)) hexChars++;
|
|
@@ -493,7 +481,7 @@ const DICT_WORDS: ReadonlySet<string> = new Set(
|
|
|
493
481
|
* "devstral-small-2" → finds "dev", "str", "small" → covers 11/16 chars
|
|
494
482
|
* "aB3xK9mPqR7wN" → no words found → dictRatio = 0
|
|
495
483
|
*/
|
|
496
|
-
function computeDictRatio(token: string): number {
|
|
484
|
+
export function computeDictRatio(token: string): number {
|
|
497
485
|
// Extract lowercase letter sequences (>= 3 chars)
|
|
498
486
|
const lowerSeqs: string[] = [];
|
|
499
487
|
let current = "";
|
|
@@ -535,19 +523,19 @@ function computeDictRatio(token: string): number {
|
|
|
535
523
|
|
|
536
524
|
// ── Entropy Constants ────────────────────────────────────────────────────────
|
|
537
525
|
|
|
538
|
-
const ENTROPY_THRESHOLD = 5.5;
|
|
539
|
-
const MIN_ENTROPY_TOKEN_LENGTH = 16;
|
|
540
|
-
const W1_DENSITY = 3.0;
|
|
541
|
-
const W2_WORD = 3.0;
|
|
542
|
-
const W3_DICT = 4.0;
|
|
543
|
-
const HEX_PENALTY = 2.5;
|
|
544
|
-
const HEX_RATIO_THRESHOLD = 0.9;
|
|
526
|
+
export const ENTROPY_THRESHOLD = 5.5;
|
|
527
|
+
export const MIN_ENTROPY_TOKEN_LENGTH = 16;
|
|
528
|
+
export const W1_DENSITY = 3.0;
|
|
529
|
+
export const W2_WORD = 3.0;
|
|
530
|
+
export const W3_DICT = 4.0;
|
|
531
|
+
export const HEX_PENALTY = 2.5;
|
|
532
|
+
export const HEX_RATIO_THRESHOLD = 0.9;
|
|
545
533
|
|
|
546
534
|
/**
|
|
547
535
|
* Adjusted entropy v3+Dict:
|
|
548
536
|
* adjusted = baseShannon + trigramDensity×W1 - wordRatio×W2 - dictRatio×W3 - hexPenalty
|
|
549
537
|
*/
|
|
550
|
-
function calculateAdjustedEntropy(data: string): number {
|
|
538
|
+
export function calculateAdjustedEntropy(data: string): number {
|
|
551
539
|
const base = shannonEntropy(data);
|
|
552
540
|
const density = maxSegmentDensity(data);
|
|
553
541
|
const wordRatio = computeWordRatio(data);
|
|
@@ -563,7 +551,7 @@ function calculateAdjustedEntropy(data: string): number {
|
|
|
563
551
|
return base + densityBoost - wordPenalty - dictPenalty - hp;
|
|
564
552
|
}
|
|
565
553
|
|
|
566
|
-
function isHighEntropy(data: string): boolean {
|
|
554
|
+
export function isHighEntropy(data: string): boolean {
|
|
567
555
|
if (data.length < MIN_ENTROPY_TOKEN_LENGTH) return false;
|
|
568
556
|
if (isSafeContent(data)) return false;
|
|
569
557
|
return calculateAdjustedEntropy(data) > ENTROPY_THRESHOLD;
|
|
@@ -573,14 +561,14 @@ function isHighEntropy(data: string): boolean {
|
|
|
573
561
|
* Split by whitespace only — the most conservative tokenization.
|
|
574
562
|
* This preserves JSON structure, URLs, and connection strings.
|
|
575
563
|
*/
|
|
576
|
-
function findHighEntropyTokens(content: string): string[] {
|
|
564
|
+
export function findHighEntropyTokens(content: string): string[] {
|
|
577
565
|
const tokens = content.split(/[\s\[\]{}"',\/\\|()&#@!<>?]+/);
|
|
578
566
|
return tokens.filter(t => t.length >= MIN_ENTROPY_TOKEN_LENGTH && isHighEntropy(t));
|
|
579
567
|
}
|
|
580
568
|
|
|
581
569
|
// ── Known Secret Patterns ────────────────────────────────────────────────────
|
|
582
570
|
|
|
583
|
-
interface SecretPattern {
|
|
571
|
+
export interface SecretPattern {
|
|
584
572
|
name: string;
|
|
585
573
|
pattern: RegExp;
|
|
586
574
|
minLength: number;
|
|
@@ -589,7 +577,7 @@ interface SecretPattern {
|
|
|
589
577
|
highConfidence: boolean;
|
|
590
578
|
}
|
|
591
579
|
|
|
592
|
-
const SECRET_PATTERNS: SecretPattern[] = [
|
|
580
|
+
export const SECRET_PATTERNS: SecretPattern[] = [
|
|
593
581
|
// AWS
|
|
594
582
|
{ name: "AWS Access Key ID", pattern: /AKIA[0-9A-Z]{16}/, minLength: 16, allowsSpaces: false, highConfidence: true },
|
|
595
583
|
{ name: "AWS Secret Access Key", pattern: /(?:aws)?_?(?:secret)?_?(?:access)?_?key['"\s:=]+['"]?[0-9a-zA-Z/+]{40}['"]?/i, minLength: 30, allowsSpaces: false, highConfidence: true },
|
|
@@ -646,7 +634,7 @@ const SECRET_PATTERNS: SecretPattern[] = [
|
|
|
646
634
|
|
|
647
635
|
// ── Safe Patterns (exclude from detection to reduce false positives) ─────────
|
|
648
636
|
|
|
649
|
-
const SAFE_PATTERNS: RegExp[] = [
|
|
637
|
+
export const SAFE_PATTERNS: RegExp[] = [
|
|
650
638
|
/^https?:\/\/[a-zA-Z0-9.-]+(?:\/[a-zA-Z0-9.\/_\-?&=#%]*)?$/, // URLs without credentials
|
|
651
639
|
/^\.\.?\/[a-zA-Z0-9_\-./]+$/, // Relative file paths
|
|
652
640
|
/^\/[a-zA-Z0-9_\-./]+$/, // Absolute Unix paths
|
|
@@ -660,7 +648,7 @@ const SAFE_PATTERNS: RegExp[] = [
|
|
|
660
648
|
/^@[a-z0-9-]+\/[a-z0-9-]+$/, // npm scoped packages
|
|
661
649
|
];
|
|
662
650
|
|
|
663
|
-
function isSafeContent(content: string): boolean {
|
|
651
|
+
export function isSafeContent(content: string): boolean {
|
|
664
652
|
for (const pat of SAFE_PATTERNS) {
|
|
665
653
|
if (pat.test(content)) return true;
|
|
666
654
|
}
|
|
@@ -669,7 +657,7 @@ function isSafeContent(content: string): boolean {
|
|
|
669
657
|
|
|
670
658
|
// ── Detector ─────────────────────────────────────────────────────────────────
|
|
671
659
|
|
|
672
|
-
interface SecretMatch {
|
|
660
|
+
export interface SecretMatch {
|
|
673
661
|
name: string;
|
|
674
662
|
start: number;
|
|
675
663
|
end: number;
|
|
@@ -678,7 +666,7 @@ interface SecretMatch {
|
|
|
678
666
|
|
|
679
667
|
const MIN_SCAN_LENGTH = 10;
|
|
680
668
|
|
|
681
|
-
function detectSecrets(content: string): SecretMatch[] {
|
|
669
|
+
export function detectSecrets(content: string): SecretMatch[] {
|
|
682
670
|
if (content.length < MIN_SCAN_LENGTH) return [];
|
|
683
671
|
const matches: SecretMatch[] = [];
|
|
684
672
|
const seen = new Set<string>(); // deduplicate by position
|
|
@@ -741,136 +729,8 @@ function detectSecrets(content: string): SecretMatch[] {
|
|
|
741
729
|
return matches.sort((a, b) => b.start - a.start);
|
|
742
730
|
}
|
|
743
731
|
|
|
744
|
-
function maskSecret(text: string): string {
|
|
732
|
+
export function maskSecret(text: string): string {
|
|
745
733
|
if (text.length <= 8) return "********";
|
|
746
734
|
return text.slice(0, 4) + "********" + text.slice(-4);
|
|
747
735
|
}
|
|
748
736
|
|
|
749
|
-
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
750
|
-
|
|
751
|
-
export function setupSafety(pi: ExtensionAPI) {
|
|
752
|
-
// ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
|
|
753
|
-
|
|
754
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
755
|
-
|
|
756
|
-
// Gate 1: 危险命令 + 覆盖写入 + 读取保护路径
|
|
757
|
-
if (event.toolName === "bash") {
|
|
758
|
-
const command = (event.input as { command?: string }).command;
|
|
759
|
-
if (command) {
|
|
760
|
-
const dangers = collectBashDangers(command, ctx.cwd);
|
|
761
|
-
if (dangers.length > 0) {
|
|
762
|
-
const message = formatBashDangers(dangers)!;
|
|
763
|
-
if (!ctx.hasUI) {
|
|
764
|
-
return { block: true, reason: `\u26D4 ${message} (non-interactive)` };
|
|
765
|
-
}
|
|
766
|
-
const choice = await ctx.ui.select(
|
|
767
|
-
`\u26A0\uFE0F ${message}\n\nAllow execution?`,
|
|
768
|
-
["Block", "Allow once"],
|
|
769
|
-
);
|
|
770
|
-
if (!choice || choice === "Block") {
|
|
771
|
-
return { block: true, reason: `\u26D4 ${message}` };
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Gate 2: write/edit 写入保护路径
|
|
778
|
-
if (event.toolName === "write" || event.toolName === "edit") {
|
|
779
|
-
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
780
|
-
if (filePath) {
|
|
781
|
-
const danger = checkProtectedPath(filePath);
|
|
782
|
-
if (danger) {
|
|
783
|
-
if (!ctx.hasUI) {
|
|
784
|
-
return { block: true, reason: `\uD83D\uDD10 ${danger}\nmay contain sensitive information` };
|
|
785
|
-
}
|
|
786
|
-
const choice = await ctx.ui.select(
|
|
787
|
-
`\uD83D\uDD10 ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
788
|
-
["Block", "Allow once"],
|
|
789
|
-
);
|
|
790
|
-
if (!choice || choice === "Block") {
|
|
791
|
-
return { block: true, reason: `\uD83D\uDD10 ${danger}\nmay contain sensitive information` };
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
|
|
798
|
-
if (event.toolName === "write") {
|
|
799
|
-
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
800
|
-
if (filePath) {
|
|
801
|
-
try {
|
|
802
|
-
const abs = resolve(ctx.cwd, filePath);
|
|
803
|
-
if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
|
|
804
|
-
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
|
|
805
|
-
}
|
|
806
|
-
} catch { /* file doesn't exist */ }
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// Gate 4: read 工具读取保护路径(bash 读取已在 Gate 1 处理)
|
|
811
|
-
if (event.toolName === "read") {
|
|
812
|
-
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
813
|
-
if (filePath) {
|
|
814
|
-
const danger = checkProtectedPath(filePath);
|
|
815
|
-
if (danger) {
|
|
816
|
-
if (!ctx.hasUI) {
|
|
817
|
-
return { block: true, reason: `\uD83D\uDD10 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
818
|
-
}
|
|
819
|
-
const choice = await ctx.ui.select(
|
|
820
|
-
`\uD83D\uDD10 Reading protected file: ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
821
|
-
["Block", "Allow once"],
|
|
822
|
-
);
|
|
823
|
-
if (!choice || choice === "Block") {
|
|
824
|
-
return { block: true, reason: `\uD83D\uDD10 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
// ── Secret Redact (tool_result) ────────────────────────────────────────
|
|
832
|
-
|
|
833
|
-
const handleToolResult = async (
|
|
834
|
-
event: ToolResultEvent,
|
|
835
|
-
ctx: ExtensionContext,
|
|
836
|
-
): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
|
|
837
|
-
if (!event.content || !Array.isArray(event.content)) return;
|
|
838
|
-
|
|
839
|
-
// Only scan read tool output — other tools (bash, write, edit) are either
|
|
840
|
-
// covered by path guards or produce git/diff noise that causes false positives.
|
|
841
|
-
if (event.toolName !== "read") return;
|
|
842
|
-
|
|
843
|
-
const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
|
|
844
|
-
for (let i = 0; i < event.content.length; i++) {
|
|
845
|
-
const item = event.content[i];
|
|
846
|
-
if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
|
|
847
|
-
textParts.push({ index: i, text: item.text, item });
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
if (textParts.length === 0) return;
|
|
851
|
-
|
|
852
|
-
let totalCount = 0;
|
|
853
|
-
const newContent = [...event.content];
|
|
854
|
-
|
|
855
|
-
for (const { index, text, item } of textParts) {
|
|
856
|
-
const matches = detectSecrets(text);
|
|
857
|
-
if (matches.length === 0) continue;
|
|
858
|
-
|
|
859
|
-
totalCount += matches.length;
|
|
860
|
-
let redacted = text;
|
|
861
|
-
for (const { start, end } of matches) {
|
|
862
|
-
const original = redacted.slice(start, end);
|
|
863
|
-
redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
|
|
864
|
-
}
|
|
865
|
-
const updatedItem: ToolTextContent = { ...item, text: redacted };
|
|
866
|
-
newContent[index] = updatedItem;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (totalCount === 0) return;
|
|
870
|
-
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
871
|
-
ctx.ui.notify(`\uD83D\uDD10 Redacted ${label} in ${event.toolName} output`, "warning");
|
|
872
|
-
return { content: newContent };
|
|
873
|
-
};
|
|
874
|
-
|
|
875
|
-
pi.on("tool_result", handleToolResult);
|
|
876
|
-
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety — Pi 集成层
|
|
3
|
+
*
|
|
4
|
+
* - Command Guard: 拦截危险 bash 命令
|
|
5
|
+
* - Redirect Guard: bash 覆盖写入提示确认
|
|
6
|
+
* - Protected Paths: write/edit/read 保护路径提示确认
|
|
7
|
+
* - Write Guard: 覆盖非空文件禁止 write
|
|
8
|
+
* - Secret Redact: API Key / Token 自动掩码
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
ToolResultEvent,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
checkProtectedPath,
|
|
20
|
+
collectBashDangers,
|
|
21
|
+
formatBashDangers,
|
|
22
|
+
detectSecrets,
|
|
23
|
+
maskSecret,
|
|
24
|
+
} from "./detect.js";
|
|
25
|
+
|
|
26
|
+
type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
|
|
27
|
+
|
|
28
|
+
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export function setupSafety(pi: ExtensionAPI) {
|
|
31
|
+
// ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
|
|
32
|
+
|
|
33
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
34
|
+
|
|
35
|
+
// Gate 1: 危险命令 + 覆盖写入 + 读取保护路径
|
|
36
|
+
if (event.toolName === "bash") {
|
|
37
|
+
const command = (event.input as { command?: string }).command;
|
|
38
|
+
if (command) {
|
|
39
|
+
const dangers = collectBashDangers(command, ctx.cwd);
|
|
40
|
+
if (dangers.length > 0) {
|
|
41
|
+
const message = formatBashDangers(dangers)!;
|
|
42
|
+
if (!ctx.hasUI) {
|
|
43
|
+
return { block: true, reason: `⚠ ${message} (non-interactive)` };
|
|
44
|
+
}
|
|
45
|
+
const choice = await ctx.ui.select(
|
|
46
|
+
`⚠️ ${message}\n\nAllow execution?`,
|
|
47
|
+
["Block", "Allow once"],
|
|
48
|
+
);
|
|
49
|
+
if (!choice || choice === "Block") {
|
|
50
|
+
return { block: true, reason: `⚠ ${message}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Gate 2: write/edit 写入保护路径
|
|
57
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
58
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
59
|
+
if (filePath) {
|
|
60
|
+
const danger = checkProtectedPath(filePath);
|
|
61
|
+
if (danger) {
|
|
62
|
+
if (!ctx.hasUI) {
|
|
63
|
+
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
64
|
+
}
|
|
65
|
+
const choice = await ctx.ui.select(
|
|
66
|
+
`🔒 ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
67
|
+
["Block", "Allow once"],
|
|
68
|
+
);
|
|
69
|
+
if (!choice || choice === "Block") {
|
|
70
|
+
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
|
|
77
|
+
if (event.toolName === "write") {
|
|
78
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
79
|
+
if (filePath) {
|
|
80
|
+
try {
|
|
81
|
+
const abs = resolve(ctx.cwd, filePath);
|
|
82
|
+
if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
|
|
83
|
+
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
|
|
84
|
+
}
|
|
85
|
+
} catch { /* file doesn't exist */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Gate 4: read 工具读取保护路径(bash 读取已在 Gate 1 处理)
|
|
90
|
+
if (event.toolName === "read") {
|
|
91
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
92
|
+
if (filePath) {
|
|
93
|
+
const danger = checkProtectedPath(filePath);
|
|
94
|
+
if (danger) {
|
|
95
|
+
if (!ctx.hasUI) {
|
|
96
|
+
return { block: true, reason: `🔒 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
97
|
+
}
|
|
98
|
+
const choice = await ctx.ui.select(
|
|
99
|
+
`🔒 Reading protected file: ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
100
|
+
["Block", "Allow once"],
|
|
101
|
+
);
|
|
102
|
+
if (!choice || choice === "Block") {
|
|
103
|
+
return { block: true, reason: `🔒 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Secret Redact (tool_result) ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const handleToolResult = async (
|
|
113
|
+
event: ToolResultEvent,
|
|
114
|
+
ctx: ExtensionContext,
|
|
115
|
+
): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
|
|
116
|
+
if (!event.content || !Array.isArray(event.content)) return;
|
|
117
|
+
|
|
118
|
+
// Only scan read tool output — other tools (bash, write, edit) are either
|
|
119
|
+
// covered by path guards or produce git/diff noise that causes false positives.
|
|
120
|
+
if (event.toolName !== "read") return;
|
|
121
|
+
|
|
122
|
+
const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
|
|
123
|
+
for (let i = 0; i < event.content.length; i++) {
|
|
124
|
+
const item = event.content[i];
|
|
125
|
+
if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
|
|
126
|
+
textParts.push({ index: i, text: item.text, item });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (textParts.length === 0) return;
|
|
130
|
+
|
|
131
|
+
let totalCount = 0;
|
|
132
|
+
const newContent = [...event.content];
|
|
133
|
+
|
|
134
|
+
for (const { index, text, item } of textParts) {
|
|
135
|
+
const matches = detectSecrets(text);
|
|
136
|
+
if (matches.length === 0) continue;
|
|
137
|
+
|
|
138
|
+
totalCount += matches.length;
|
|
139
|
+
let redacted = text;
|
|
140
|
+
for (const { start, end } of matches) {
|
|
141
|
+
const original = redacted.slice(start, end);
|
|
142
|
+
redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
|
|
143
|
+
}
|
|
144
|
+
const updatedItem: ToolTextContent = { ...item, text: redacted };
|
|
145
|
+
newContent[index] = updatedItem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (totalCount === 0) return;
|
|
149
|
+
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
150
|
+
ctx.ui.notify(`🔒 Redacted ${label} in ${event.toolName} output`, "warning");
|
|
151
|
+
return { content: newContent };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
pi.on("tool_result", handleToolResult);
|
|
155
|
+
}
|
package/extensions/settings.ts
CHANGED
|
@@ -25,10 +25,17 @@ export interface ProviderCache {
|
|
|
25
25
|
models: ProviderModelEntry[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface ModuleSettings {
|
|
29
|
+
safety?: boolean;
|
|
30
|
+
lsp?: boolean;
|
|
31
|
+
"smart-at"?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
export interface DecoratedPiConfig {
|
|
29
35
|
imageModelKey?: string | null;
|
|
30
36
|
compactModelKey?: string | null;
|
|
31
37
|
providers?: Record<string, ProviderCache>;
|
|
38
|
+
modules?: ModuleSettings;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export function loadConfig(): DecoratedPiConfig {
|
|
@@ -97,3 +104,27 @@ export function setImageModelKey(key: string | null) {
|
|
|
97
104
|
export function setCompactModelKey(key: string | null) {
|
|
98
105
|
saveConfig({ compactModelKey: key });
|
|
99
106
|
}
|
|
107
|
+
|
|
108
|
+
// ─── Module Switches ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
111
|
+
safety: true,
|
|
112
|
+
lsp: true,
|
|
113
|
+
"smart-at": true,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function isModuleEnabled(name: keyof ModuleSettings): boolean {
|
|
117
|
+
const modules = loadConfig().modules ?? {};
|
|
118
|
+
return modules[name] ?? DEFAULT_MODULES[name] ?? true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function setModuleEnabled(name: keyof ModuleSettings, enabled: boolean) {
|
|
122
|
+
const modules = { ...loadConfig().modules };
|
|
123
|
+
modules[name] = enabled;
|
|
124
|
+
saveConfig({ modules });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getAllModuleSettings(): Required<ModuleSettings> {
|
|
128
|
+
const modules = loadConfig().modules ?? {};
|
|
129
|
+
return { ...DEFAULT_MODULES, ...modules };
|
|
130
|
+
}
|
package/extensions/slash.ts
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slash — 所有扩展命令
|
|
3
3
|
*
|
|
4
|
-
* /
|
|
5
|
-
* /
|
|
4
|
+
* /dp-model → 模型选择器 (TAB 切换 Image/Compact)
|
|
5
|
+
* /dp-settings → 模块开关 (safety / lsp / smart-at)
|
|
6
|
+
* /retry → 中断后继续
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { ModelPickerComponent } from "./extend-model.js";
|
|
11
|
+
import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
|
|
12
|
+
import { Container, SettingsList, type TUI, type Theme as PiTheme, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
|
|
10
13
|
|
|
11
|
-
// ───
|
|
14
|
+
// ─── Border component (matches native DynamicBorder) ────────────────────────
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
class DynamicBorder implements Component {
|
|
17
|
+
private colorFn: (str: string) => string;
|
|
18
|
+
|
|
19
|
+
constructor(theme: PiTheme) {
|
|
20
|
+
this.colorFn = (str: string) => theme.fg("border", str);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
invalidate() {}
|
|
24
|
+
|
|
25
|
+
render(width: number): string[] {
|
|
26
|
+
return [this.colorFn("─".repeat(Math.max(1, width)))];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── SettingsList Theme (matches native getSettingsListTheme) ───────────────
|
|
31
|
+
|
|
32
|
+
function getSettingsListTheme(theme: PiTheme): SettingsListTheme {
|
|
33
|
+
return {
|
|
34
|
+
label: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : text,
|
|
35
|
+
value: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
|
|
36
|
+
description: (text: string) => theme.fg("dim", text),
|
|
37
|
+
cursor: theme.fg("accent", "→ "),
|
|
38
|
+
hint: (text: string) => theme.fg("dim", text),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── /dp-model ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function setupDpModelCommand(pi: ExtensionAPI) {
|
|
45
|
+
pi.registerCommand("dp-model", {
|
|
15
46
|
description: "Configure image and compact models",
|
|
16
47
|
handler: async (_args, ctx) => {
|
|
17
48
|
if (ctx.hasUI) {
|
|
@@ -21,7 +52,75 @@ function setupExtendModelCommand(pi: ExtensionAPI) {
|
|
|
21
52
|
);
|
|
22
53
|
return;
|
|
23
54
|
}
|
|
24
|
-
ctx.ui.notify("
|
|
55
|
+
ctx.ui.notify("dp-model requires interactive mode.", "warning");
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── /dp-settings ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
|
|
63
|
+
safety: "Safety Layer",
|
|
64
|
+
lsp: "LSP Tools",
|
|
65
|
+
"smart-at": "Smart @ Search",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
|
|
69
|
+
safety: "Command guard, protected paths, read guard, secret redaction",
|
|
70
|
+
lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
|
|
71
|
+
"smart-at": "Project-aware file search replacing default autocomplete",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
class ModuleSettingsComponent extends Container {
|
|
75
|
+
private settingsList: SettingsList;
|
|
76
|
+
|
|
77
|
+
constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
|
|
78
|
+
super();
|
|
79
|
+
const modules = getAllModuleSettings();
|
|
80
|
+
const keys = Object.keys(MODULE_LABELS) as (keyof ModuleSettings)[];
|
|
81
|
+
|
|
82
|
+
const items = keys.map(k => ({
|
|
83
|
+
id: k,
|
|
84
|
+
label: MODULE_LABELS[k],
|
|
85
|
+
description: MODULE_DESCS[k],
|
|
86
|
+
currentValue: modules[k] ? "on" : "off",
|
|
87
|
+
values: ["on", "off"],
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
this.addChild(new DynamicBorder(theme));
|
|
91
|
+
|
|
92
|
+
this.settingsList = new SettingsList(
|
|
93
|
+
items, 10, getSettingsListTheme(theme),
|
|
94
|
+
(id: string, newValue: string) => {
|
|
95
|
+
setModuleEnabled(id as keyof ModuleSettings, newValue === "on");
|
|
96
|
+
tui.requestRender();
|
|
97
|
+
},
|
|
98
|
+
() => onDone(),
|
|
99
|
+
{ enableSearch: true },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
this.addChild(this.settingsList);
|
|
103
|
+
this.addChild(new DynamicBorder(theme));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
handleInput(data: string) {
|
|
107
|
+
this.settingsList.handleInput(data);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setupDpSettingsCommand(pi: ExtensionAPI) {
|
|
112
|
+
pi.registerCommand("dp-settings", {
|
|
113
|
+
description: "Toggle decorated-pi modules on/off",
|
|
114
|
+
handler: async (_args, ctx) => {
|
|
115
|
+
if (ctx.hasUI) {
|
|
116
|
+
await ctx.ui.custom<void>(
|
|
117
|
+
(tui, theme, _kb, done) =>
|
|
118
|
+
new ModuleSettingsComponent(tui, theme, () => done(undefined))
|
|
119
|
+
);
|
|
120
|
+
ctx.ui.notify("Module settings updated. /reload to apply.", "info");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
ctx.ui.notify("dp-settings requires interactive mode.", "warning");
|
|
25
124
|
},
|
|
26
125
|
});
|
|
27
126
|
}
|
|
@@ -62,6 +161,7 @@ function setupRetryCommand(pi: ExtensionAPI) {
|
|
|
62
161
|
// ─── 入口 ───────────────────────────────────────────────────────────────────
|
|
63
162
|
|
|
64
163
|
export function setupSlash(pi: ExtensionAPI) {
|
|
65
|
-
|
|
164
|
+
setupDpModelCommand(pi);
|
|
165
|
+
setupDpSettingsCommand(pi);
|
|
66
166
|
setupRetryCommand(pi);
|
|
67
167
|
}
|
package/package.json
CHANGED