reader-shell 1.0.4 → 1.0.6

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 CHANGED
@@ -1,11 +1,13 @@
1
1
  # 终端电子书阅读器
2
2
 
3
- 一个基于 Node.js 的终端电子书阅读器,支持导入 epub 文件、记住阅读进度、快捷键翻页。
3
+ 一个基于 Node.js Ink 的全屏终端电子书阅读器,支持导入 epub/txt 文件、记住阅读进度、快捷键翻页。
4
4
 
5
5
  ![reader-shell-demo.gif?raw=true](https://cdn.jsdelivr.net/gh/jichangee/gallery@master/imgur/reader-shell-demo.gif?raw=true)
6
6
 
7
7
  ## 功能特性
8
- - 支持导入 epub 电子书
8
+ - 支持导入 epub、txt 电子书
9
+ - Codex CLI 风格的全屏阅读界面
10
+ - 根据终端尺寸自动调整正文宽度和页高
9
11
  - 记住每本书的阅读进度(包括章节和章节内滚动位置)
10
12
  - 支持快捷键翻页、章节内滚动
11
13
  - 支持老板键一键隐藏内容
@@ -13,6 +15,8 @@
13
15
 
14
16
  ## 安装依赖
15
17
 
18
+ 需要 Node.js 22.6 或更高版本。
19
+
16
20
  ```bash
17
21
  npm install
18
22
  ```
@@ -33,6 +37,9 @@ node src/ui.js ./books/三体.epub
33
37
  - `←` 或 `w`:上一章
34
38
  - `↑` 或 `s`:章节内向上滚动
35
39
  - `↓` 或 `d`:章节内向下滚动
40
+ - `PageUp` / `PageDown`:整页滚动
41
+ - `Home` / `End`:跳到章节开头/结尾
42
+ - `Space`:向下翻页
36
43
  - `a`:老板键(快速隐藏/恢复内容)
37
44
  - `q` 或 `Ctrl+C`:退出阅读器
38
45
 
@@ -46,8 +53,8 @@ node src/ui.js ./books/三体.epub
46
53
 
47
54
  ## 依赖说明
48
55
  - [epub](https://www.npmjs.com/package/epub) 电子书解析
49
- - [blessed](https://www.npmjs.com/package/blessed) 终端 UI
50
- - [chalk](https://www.npmjs.com/package/chalk) 终端字体颜色
56
+ - [Ink](https://www.npmjs.com/package/ink) 终端 UI
57
+ - [React](https://www.npmjs.com/package/react) 组件与状态管理
51
58
 
52
59
  ## 许可协议
53
60
  MIT
package/cli.js CHANGED
@@ -6,4 +6,9 @@ if (!filePath) {
6
6
  console.error('用法: reader-shell <文件路径>');
7
7
  process.exit(1);
8
8
  }
9
- startReader(path.resolve(filePath));
9
+ try {
10
+ await startReader(path.resolve(filePath));
11
+ } catch (error) {
12
+ console.error(error instanceof Error ? error.message : error);
13
+ process.exitCode = 1;
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reader-shell",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "main": "cli.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -10,13 +10,21 @@
10
10
  "author": "",
11
11
  "license": "ISC",
12
12
  "description": "",
13
+ "files": [
14
+ "cli.js",
15
+ "src"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22.6.0"
19
+ },
13
20
  "dependencies": {
14
- "blessed": "^0.1.81",
15
- "chalk": "^5.4.1",
16
- "epub": "^1.3.0",
17
- "iconv-lite": "^0.7.0"
21
+ "epub": "^2.1.1",
22
+ "iconv-lite": "^0.7.0",
23
+ "ink": "^7.1.1",
24
+ "react": "^19.3.0",
25
+ "wrap-ansi": "^10.0.2"
18
26
  },
19
27
  "bin": {
20
- "reader-shell": "./cli.js"
28
+ "reader-shell": "cli.js"
21
29
  }
22
30
  }
package/src/config.js CHANGED
@@ -1,6 +1,10 @@
1
1
  export const config = {
2
- fontColor: "white",
3
- title: "node",
2
+ readingWidth: 88,
3
+ colors: {
4
+ accent: "cyan",
5
+ border: "gray",
6
+ text: "white",
7
+ },
4
8
  bossContent: ` @ multi (webpack)-dev-server/client?http://localhost:8081/sockjs-node (webpack)/hot/dev-server.js ./src/main.js
5
9
 
6
10
  warning in ./src/pages/reader/index.vue?vue&type=template&id=6430da5c&scoped=true&
@@ -15,4 +19,4 @@ export const config = {
15
19
  @ multi (webpack)-dev-server/client?http://localhost:8081/sockjs-node (webpack)/hot/dev-server.js ./src/main.js
16
20
 
17
21
  `
18
- };
22
+ };
package/src/epubReader.js CHANGED
@@ -1,35 +1,61 @@
1
1
  import EPub from 'epub';
2
2
 
3
+ function decodeHtmlEntities(text) {
4
+ const entities = {
5
+ amp: '&',
6
+ apos: "'",
7
+ gt: '>',
8
+ lt: '<',
9
+ nbsp: ' ',
10
+ quot: '"'
11
+ };
12
+
13
+ return text.replace(/&(#(?:x[0-9a-f]+|\d+)|[a-z]+);/gi, (match, entity) => {
14
+ if (entity[0] === '#') {
15
+ const isHex = entity[1].toLowerCase() === 'x';
16
+ const value = Number.parseInt(entity.slice(isHex ? 2 : 1), isHex ? 16 : 10);
17
+ return Number.isNaN(value) || value > 0x10FFFF
18
+ ? match
19
+ : String.fromCodePoint(value);
20
+ }
21
+
22
+ return entities[entity.toLowerCase()] ?? match;
23
+ });
24
+ }
25
+
26
+ function htmlToReadableText(html) {
27
+ const text = html
28
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '')
29
+ .replace(/<br\s*\/?\s*>/gi, '\n')
30
+ .replace(/<\/(?:p|div|section|article|blockquote|h[1-6]|li)>/gi, '\n\n')
31
+ .replace(/<li[^>]*>/gi, '• ')
32
+ .replace(/<[^>]+>/g, '');
33
+
34
+ return decodeHtmlEntities(text)
35
+ .replace(/\r\n?/g, '\n')
36
+ .replace(/[ \t]+\n/g, '\n')
37
+ .replace(/\n[ \t]+/g, '\n')
38
+ .replace(/\n{3,}/g, '\n\n')
39
+ .trim();
40
+ }
41
+
3
42
  /**
4
43
  * 解析epub文件,返回章节文本数组
5
44
  * @param {string} epubPath epub文件路径
6
45
  * @returns {Promise<string[]>} 章节文本数组
7
46
  */
8
- function parseEpub(epubPath) {
9
- return new Promise((resolve, reject) => {
10
- const epub = new EPub(epubPath);
11
- epub.on('error', reject);
12
- epub.on('end', function() {
13
- const chapterIds = epub.flow.map(ch => ch.id);
14
- const chapters = [];
15
- let loaded = 0;
16
- chapterIds.forEach((id, idx) => {
17
- epub.getChapterRaw(id, (err, data) => {
18
- if (err) {
19
- chapters[idx] = '';
20
- } else {
21
- let text = data.toString('utf-8');
22
- chapters[idx] = text.replace(/<[^>]+>/g, '').replace(/\r\n/g, '');
23
- }
24
- loaded++;
25
- if (loaded === chapterIds.length) {
26
- resolve(chapters);
27
- }
28
- });
29
- });
30
- });
31
- epub.parse();
32
- });
47
+ async function parseEpub(epubPath) {
48
+ const epub = new EPub(epubPath);
49
+ await epub.parse();
50
+
51
+ return Promise.all(epub.flow.map(async ({ id }) => {
52
+ try {
53
+ const text = await epub.getChapterRaw(id);
54
+ return htmlToReadableText(text);
55
+ } catch {
56
+ return '';
57
+ }
58
+ }));
33
59
  }
34
60
 
35
61
  export { parseEpub };
package/src/txtReader.js CHANGED
@@ -150,7 +150,9 @@ function parseTxt(txtPath) {
150
150
  chapters = chapters.map(chapter => {
151
151
  return chapter
152
152
  .replace(/\r\n/g, '\n') // 统一换行符
153
- .replace(/\n{3,}/g, '\n\n') // 去除多余的空行
153
+ .replace(/[ \t]+\n/g, '\n') // 去除行尾空白
154
+ .replace(/\n[ \t]+/g, '\n') // 去除空行中的空白
155
+ .replace(/\n{3,}/g, '\n\n') // 合并过多的空行
154
156
  .trim();
155
157
  }).filter(chapter => chapter.length > 0);
156
158
 
@@ -161,4 +163,4 @@ function parseTxt(txtPath) {
161
163
  });
162
164
  }
163
165
 
164
- export { parseTxt, detectEncoding, readFileWithCorrectEncoding };
166
+ export { parseTxt, detectEncoding, readFileWithCorrectEncoding };
package/src/ui.js CHANGED
@@ -1,179 +1,274 @@
1
- import blessed from "blessed";
2
- import chalk from "chalk";
1
+ import path from "path";
2
+ import { pathToFileURL } from "url";
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
5
+ import wrapAnsi from "wrap-ansi";
3
6
  import { parseEpub } from "./epubReader.js";
4
7
  import { parseTxt } from "./txtReader.js";
5
- import path from "path";
6
8
  import { getBookProgress, setBookProgress } from "./progressStore.js";
7
9
  import { config } from "./config.js";
8
- import fs from 'fs';
9
-
10
- // 默认字体颜色
11
- let fontColor = config.fontColor;
12
- const colorFns = {
13
- white: chalk.white,
14
- green: chalk.green,
15
- cyan: chalk.cyan,
16
- magenta: chalk.magenta,
17
- yellow: chalk.yellow,
18
- };
19
10
 
20
- async function startReader(filePath) {
21
- const ext = path.extname(filePath).toLowerCase();
22
- let chapters;
11
+ const h = React.createElement;
23
12
 
24
- if (ext === '.epub') {
25
- chapters = await parseEpub(filePath);
26
- } else if (ext === '.txt') {
27
- chapters = await parseTxt(filePath);
28
- } else {
29
- throw new Error(`不支持的文件格式: ${ext}`);
30
- }
13
+ function formatChapterParagraphs(content) {
14
+ const lines = content
15
+ .replace(/\r\n?/g, "\n")
16
+ .split("\n")
17
+ .map((line) => line.trim())
18
+ .filter(Boolean);
31
19
 
32
- const progress = getBookProgress(filePath);
33
- let currentChapter = (progress && progress.chapter) || 0;
34
- let currentScroll = (progress && progress.scroll) || 0;
20
+ return lines.map((line, index) => {
21
+ const isHeading = /^第[一二三四五六七八九十百千万零〇0-9]+[章节卷部篇回]/.test(line)
22
+ || index === 0 && line.length <= 30;
35
23
 
36
- // 创建blessed屏幕
37
- const screen = blessed.screen({
38
- smartCSR: true,
39
- fullUnicode: true,
40
- title: config.title,
24
+ return {
25
+ text: isHeading ? line : `  ${line}`,
26
+ isHeading,
27
+ };
41
28
  });
29
+ }
42
30
 
43
- // 掩人耳目,假装在开发
44
- const boss = blessed.box({
45
- top: 0,
46
- left: 0,
47
- width: "100%",
48
- height: "70%",
49
- border: "none",
50
- content: config.bossContent
51
- });
31
+ function buildDisplayLines(content, width) {
32
+ const result = [];
52
33
 
53
- // 章节内容框
54
- const box = blessed.box({
55
- top: "70%",
56
- left: 0,
57
- width: "300",
58
- height: "30%",
59
- tags: true,
60
- scrollable: true,
61
- alwaysScroll: true,
62
- keys: true,
63
- focused: true,
64
- mouse: true,
65
- vi: true,
66
- border: "none",
67
- style: {
68
- fg: fontColor,
69
- border: { fg: "cyan" },
70
- },
71
- });
34
+ for (const paragraph of formatChapterParagraphs(content)) {
35
+ const wrappedLines = wrapAnsi(paragraph.text, width, {
36
+ hard: true,
37
+ trim: false,
38
+ wordWrap: true,
39
+ }).split("\n");
72
40
 
73
- // 状态栏
74
- const status = blessed.box({
75
- bottom: 0,
76
- left: 0,
77
- width: "100%",
78
- height: "10%",
79
- tags: true,
80
- style: { fg: fontColor },
81
- });
82
- screen.append(boss);
83
- screen.append(box);
84
- screen.append(status);
41
+ result.push(...wrappedLines.map((text) => ({
42
+ text,
43
+ isHeading: paragraph.isHeading,
44
+ })));
45
+ result.push({ text: "", isHeading: false });
46
+ }
47
+
48
+ if (result.length > 0) {
49
+ result.pop();
50
+ }
51
+
52
+ return result;
53
+ }
54
+
55
+ function ProgressBar({ value, width }) {
56
+ const safeValue = Math.max(0, Math.min(1, value));
57
+ const completed = Math.round(width * safeValue);
58
+
59
+ return h(Text, null,
60
+ h(Text, { color: config.colors.accent }, "━".repeat(completed)),
61
+ h(Text, { dimColor: true }, "─".repeat(width - completed)),
62
+ );
63
+ }
85
64
 
86
- // 用于跟踪当前显示的章节,避免重复设置相同内容
87
- let lastDisplayedChapter = -1;
65
+ function BossView({ rows }) {
66
+ const visibleContent = config.bossContent
67
+ .trim()
68
+ .split("\n")
69
+ .slice(0, Math.max(1, rows - 3))
70
+ .join("\n");
88
71
 
89
- function render(forceContentRefresh = false) {
90
- const colorFn = colorFns[fontColor] || chalk.white;
72
+ return h(Box, {
73
+ height: rows,
74
+ flexDirection: "column",
75
+ paddingX: 2,
76
+ paddingTop: 1,
77
+ },
78
+ h(Text, { color: "yellow" }, visibleContent),
79
+ h(Box, { marginTop: 1 }, h(Text, { dimColor: true }, "Press a to return")),
80
+ );
81
+ }
82
+
83
+ function ReaderApp({ chapters, filePath, initialProgress }) {
84
+ const { exit } = useApp();
85
+ const { columns, rows } = useWindowSize();
86
+ const [chapter, setChapter] = useState(() => Math.min(
87
+ Math.max(initialProgress?.chapter ?? 0, 0),
88
+ Math.max(chapters.length - 1, 0),
89
+ ));
90
+ const [scroll, setScroll] = useState(() => Math.max(initialProgress?.scroll ?? 0, 0));
91
+ const [isDisguised, setIsDisguised] = useState(false);
92
+
93
+ const panelWidth = Math.min(config.readingWidth + 6, Math.max(20, columns - 2));
94
+ const contentWidth = Math.max(12, panelWidth - 6);
95
+ const viewportHeight = Math.max(3, rows - 9);
96
+ const lines = useMemo(
97
+ () => buildDisplayLines(chapters[chapter] || "", contentWidth),
98
+ [chapter, chapters, contentWidth],
99
+ );
100
+ const maxScroll = Math.max(0, lines.length - viewportHeight);
101
+ const safeScroll = Math.min(scroll, maxScroll);
102
+ const visibleLines = lines.slice(safeScroll, safeScroll + viewportHeight);
103
+ const progress = chapters.length === 0
104
+ ? 0
105
+ : (chapter + (maxScroll === 0 ? 1 : safeScroll / maxScroll)) / chapters.length;
106
+ const progressWidth = Math.max(8, Math.min(28, panelWidth - 22));
91
107
 
92
- // 只有当章节改变或强制刷新时才重新设置内容
93
- if (forceContentRefresh || lastDisplayedChapter !== currentChapter) {
94
- box.setContent(colorFn(chapters[currentChapter] || ""));
95
- lastDisplayedChapter = currentChapter;
108
+ useEffect(() => {
109
+ if (scroll > maxScroll) {
110
+ setScroll(maxScroll);
96
111
  }
112
+ }, [maxScroll, scroll]);
97
113
 
98
- status.setContent(
99
- `progress: ${currentScroll}/${box.getScrollHeight()} | chapter: ${currentChapter + 1}/${
100
- chapters.length
101
- } | ←/w:prev →/e:next ↑/s:up ↓/d:down a:boss q:exit`
102
- );
103
- // 恢复滚动位置
104
- box.setScroll(currentScroll);
105
- screen.render();
106
- }
114
+ useEffect(() => {
115
+ setBookProgress(filePath, { chapter, scroll: safeScroll });
116
+ }, [chapter, filePath, safeScroll]);
107
117
 
108
- function saveProgress() {
109
- setBookProgress(filePath, { chapter: currentChapter, scroll: currentScroll });
118
+ function changeChapter(offset) {
119
+ setChapter((current) => Math.min(Math.max(current + offset, 0), chapters.length - 1));
120
+ setScroll(0);
110
121
  }
111
122
 
112
- function changeChapter(chapter) {
113
- currentChapter += chapter;
114
- currentScroll = 0;
115
- saveProgress();
116
- render(true); // 切换章节时强制刷新内容
123
+ function moveScroll(offset) {
124
+ setScroll((current) => Math.min(Math.max(current + offset, 0), maxScroll));
117
125
  }
118
126
 
119
- // 快捷键
120
- screen.key(["right", "e"], () => {
121
- if (currentChapter < chapters.length - 1) {
122
- changeChapter(1)
123
- }
124
- });
125
- screen.key(["left", "w"], () => {
126
- if (currentChapter > 0) {
127
- changeChapter(-1)
127
+ useInput((input, key) => {
128
+ if (input === "q") {
129
+ setBookProgress(filePath, { chapter, scroll: safeScroll });
130
+ exit();
131
+ return;
128
132
  }
129
- });
130
- screen.key(["up", "s"], () => {
131
- currentScroll--;
132
- if (currentScroll < 0) {
133
- changeChapter(-1)
134
- return
133
+
134
+ if (input === "a") {
135
+ setIsDisguised((value) => !value);
136
+ return;
135
137
  }
136
- render(true);
137
- });
138
- screen.key(["down", "d"], () => {
139
- currentScroll++;
140
- if (currentScroll > box.getScrollHeight()) {
141
- changeChapter(1)
142
- return
138
+
139
+ if (isDisguised) {
140
+ return;
143
141
  }
144
- render(true);
145
- });
146
- screen.key(["a"], () => {
147
- // 老板键
148
- if (box.height === 0) {
149
- boss.height = '80%'
150
- box.height = '30%'
151
- status.height = '10%'
152
- } else {
153
- boss.height = '100%'
154
- box.height = 0
155
- status.height = 0
142
+
143
+ if (key.leftArrow || input === "w") {
144
+ changeChapter(-1);
145
+ } else if (key.rightArrow || input === "e") {
146
+ changeChapter(1);
147
+ } else if (key.upArrow || input === "s") {
148
+ if (safeScroll === 0 && chapter > 0) {
149
+ changeChapter(-1);
150
+ } else {
151
+ moveScroll(-1);
152
+ }
153
+ } else if (key.downArrow || input === "d") {
154
+ if (safeScroll === maxScroll && chapter < chapters.length - 1) {
155
+ changeChapter(1);
156
+ } else {
157
+ moveScroll(1);
158
+ }
159
+ } else if (key.pageUp) {
160
+ moveScroll(-(viewportHeight - 1));
161
+ } else if (key.pageDown || input === " ") {
162
+ moveScroll(viewportHeight - 1);
163
+ } else if (key.home) {
164
+ setScroll(0);
165
+ } else if (key.end) {
166
+ setScroll(maxScroll);
156
167
  }
157
- render(false) // 老板键切换时不强制刷新内容
158
168
  });
159
- screen.key(["q", "C-c"], () => {
160
- saveProgress();
161
- process.exit(0);
169
+
170
+ if (isDisguised) {
171
+ return h(BossView, { rows });
172
+ }
173
+
174
+ const lineNodes = Array.from({ length: viewportHeight }, (_, index) => {
175
+ const line = visibleLines[index];
176
+ return h(Text, {
177
+ key: safeScroll + index,
178
+ bold: line?.isHeading,
179
+ color: line?.isHeading ? config.colors.accent : config.colors.text,
180
+ wrap: "truncate-end",
181
+ }, line?.text || " ");
162
182
  });
163
183
 
164
- box.on('scroll', () => {
165
- saveProgress();
184
+ return h(Box, {
185
+ width: columns,
186
+ height: rows,
187
+ flexDirection: "column",
188
+ paddingX: columns > 40 ? 1 : 0,
189
+ },
190
+ h(Box, {
191
+ width: panelWidth,
192
+ alignSelf: "center",
193
+ justifyContent: "space-between",
194
+ },
195
+ h(Text, { wrap: "truncate-middle" },
196
+ h(Text, { bold: true, color: config.colors.accent }, "reader-shell"),
197
+ h(Text, { dimColor: true }, ` ${path.basename(filePath)}`),
198
+ ),
199
+ h(Text, { dimColor: true, wrap: "truncate-end" }, `${chapter + 1} / ${chapters.length}`),
200
+ ),
201
+ h(Box, {
202
+ width: panelWidth,
203
+ height: viewportHeight + 4,
204
+ alignSelf: "center",
205
+ flexDirection: "column",
206
+ borderStyle: "round",
207
+ borderColor: config.colors.border,
208
+ paddingX: 2,
209
+ paddingY: 1,
210
+ marginTop: 1,
211
+ }, ...lineNodes),
212
+ h(Box, {
213
+ width: panelWidth,
214
+ alignSelf: "center",
215
+ justifyContent: "space-between",
216
+ marginTop: 1,
217
+ },
218
+ h(Box, null,
219
+ h(ProgressBar, { value: progress, width: progressWidth }),
220
+ h(Text, { dimColor: true }, ` ${Math.round(progress * 100)}%`),
221
+ ),
222
+ h(Text, { dimColor: true }, `${safeScroll + 1} / ${Math.max(lines.length, 1)}`),
223
+ ),
224
+ h(Box, { width: panelWidth, alignSelf: "center" },
225
+ h(Text, { dimColor: true, wrap: "truncate-end" }, "↑↓ scroll PgUp/PgDn page ←→ chapter a hide q quit"),
226
+ ),
227
+ );
228
+ }
229
+
230
+ async function loadChapters(filePath) {
231
+ const ext = path.extname(filePath).toLowerCase();
232
+
233
+ if (ext === ".epub") {
234
+ return parseEpub(filePath);
235
+ }
236
+
237
+ if (ext === ".txt") {
238
+ return parseTxt(filePath);
239
+ }
240
+
241
+ throw new Error(`不支持的文件格式: ${ext}`);
242
+ }
243
+
244
+ async function startReader(filePath) {
245
+ const loadedChapters = await loadChapters(filePath);
246
+ const chapters = loadedChapters.length > 0
247
+ ? loadedChapters
248
+ : ["(没有可显示的内容)"];
249
+ const instance = render(h(ReaderApp, {
250
+ chapters,
251
+ filePath,
252
+ initialProgress: getBookProgress(filePath),
253
+ }), {
254
+ alternateScreen: true,
255
+ incrementalRendering: true,
166
256
  });
167
257
 
168
- render(true); // 初始渲染时强制设置内容
258
+ return instance.waitUntilExit();
169
259
  }
170
260
 
171
- // 允许命令行直接运行
172
- const filePath = process.argv[2];
173
- if (!filePath) {
174
- console.error("用法: node src/ui.js <文件路径>");
175
- process.exit(1);
261
+ const isDirectRun = process.argv[1]
262
+ && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
263
+
264
+ if (isDirectRun) {
265
+ const filePath = process.argv[2];
266
+ if (!filePath) {
267
+ console.error("用法: node src/ui.js <文件路径>");
268
+ process.exitCode = 1;
269
+ } else {
270
+ await startReader(path.resolve(filePath));
271
+ }
176
272
  }
177
- startReader(path.resolve(filePath));
178
273
 
179
- export { startReader };
274
+ export { buildDisplayLines, formatChapterParagraphs, startReader };
@@ -1,14 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git add:*)",
5
- "Bash(git commit:*)",
6
- "Bash(git push:*)",
7
- "Bash(node:*)",
8
- "Bash(timeout:*)",
9
- "Read(//Users/xuejichang/Documents/book/**)"
10
- ],
11
- "deny": [],
12
- "ask": []
13
- }
14
- }