@jc20231028/local-code-agent 0.1.1 → 0.1.3

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
@@ -49,6 +49,13 @@ local-code chat
49
49
  npm 只會把執行檔裝進當下專案的 `node_modules/.bin`,不會加進系統 PATH,
50
50
  在 cmd 直接打 `local-code` 會抓不到指令。這種情況下要嘛加 `-g` 重裝,要嘛用 `npx local-code chat` 執行。
51
51
 
52
+ 如果你已經用沒加 `-g` 的方式裝過,先移除本地安裝再改用全域安裝:
53
+
54
+ ```powershell
55
+ npm uninstall @jc20231028/local-code-agent
56
+ npm install -g @jc20231028/local-code-agent
57
+ ```
58
+
52
59
  `provider` / `model` 留空時,`local-code chat` 第一次啟動就會直接跳出互動選單讓你選(見下方「初始化設定」),
53
60
  不需要先手動跑 `local-code init`——`init` 只是用來印出設定檔範例,不是必要步驟。
54
61
 
@@ -89,6 +96,7 @@ node ./bin/local-code.js init
89
96
  "lmStudioBaseUrl": "http://127.0.0.1:1234",
90
97
  "maxSteps": 12,
91
98
  "allowCommands": false,
99
+ "allowWrites": false,
92
100
  "temperature": 0.2
93
101
  }
94
102
  ```
@@ -133,6 +141,14 @@ node ./bin/local-code.js run "編譯並執行這個 C# 專案"
133
141
  node ./bin/local-code.js run "執行測試並修正失敗案例" --allow-commands
134
142
  ```
135
143
 
144
+ 同樣地,模型呼叫 `write_file`、`append_file`、`replace_in_file`、`make_directory` 這些會建立/覆寫/修改檔案或資料夾的工具時,預設也會先印出要變更的路徑(和內容預覽)並問 `Allow this change? [y/N]:`,按 `y` 才會真的寫入;沒有 TTY 時一樣直接安全拒絕。想跳過詢問可以加 `--allow-writes`:
145
+
146
+ ```powershell
147
+ node ./bin/local-code.js run "幫我建立這個功能的檔案" --allow-writes
148
+ ```
149
+
150
+ `run_command` 之外的其他工具(`list_files`、`read_file`、`search_text`)只是讀取,不會跳出詢問。每一步驟模型在做什麼、呼叫了哪個工具、帶了什麼參數,都會即時印在終端機(stderr),不會等到最後才一次顯示結果。
151
+
136
152
  列出目前可用的 Skill:
137
153
 
138
154
  ```powershell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc20231028/local-code-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A local coding CLI that uses Ollama or LM Studio models to inspect and modify project files.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
Binary file
package/src/cli.js CHANGED
@@ -116,8 +116,11 @@ function buildProgressHooks() {
116
116
  onStep(step) {
117
117
  console.error(`step ${step}: waiting for model...`);
118
118
  },
119
+ onReasoning({ step, text }) {
120
+ console.error(`step ${step} thinking: ${truncateForLog(text, 200)}`);
121
+ },
119
122
  onToolCall(toolCall) {
120
- console.error(`step result: called tool "${toolCall.tool}"`);
123
+ console.error(`step result: called tool "${toolCall.tool}" -> ${summarizeToolCall(toolCall)}`);
121
124
  },
122
125
  onToolCallError({ step, reason, message, attempt }) {
123
126
  const label = reason === "truncated" ? "reply cut off (length limit)" : "invalid tool call JSON";
@@ -129,6 +132,33 @@ function buildProgressHooks() {
129
132
  };
130
133
  }
131
134
 
135
+ function summarizeToolCall(toolCall) {
136
+ const args = toolCall.args ?? {};
137
+ switch (toolCall.tool) {
138
+ case "read_file":
139
+ case "make_directory":
140
+ return args.path ?? "";
141
+ case "write_file":
142
+ case "append_file":
143
+ return `${args.path ?? ""} (${String(args.content ?? "").length} chars)`;
144
+ case "replace_in_file":
145
+ return `${args.path ?? ""} (find: "${truncateForLog(args.findText ?? "", 40)}")`;
146
+ case "search_text":
147
+ return `"${args.query ?? ""}" in ${args.path ?? "."}`;
148
+ case "list_files":
149
+ return args.path ?? ".";
150
+ case "run_command":
151
+ return [args.command, ...(args.args ?? [])].join(" ");
152
+ default:
153
+ return JSON.stringify(args);
154
+ }
155
+ }
156
+
157
+ function truncateForLog(text, max) {
158
+ const flattened = text.replace(/\s+/g, " ").trim();
159
+ return flattened.length > max ? `${flattened.slice(0, max)}...` : flattened;
160
+ }
161
+
132
162
  function printResult(result) {
133
163
  if (result.failed) {
134
164
  console.log(`⚠️ ${result.content}`);
@@ -453,6 +483,7 @@ async function printInitExample(cwd) {
453
483
  lmStudioBaseUrl: "http://127.0.0.1:1234",
454
484
  maxSteps: 12,
455
485
  allowCommands: false,
486
+ allowWrites: false,
456
487
  temperature: 0.2
457
488
  };
458
489
 
@@ -518,6 +549,7 @@ Options:
518
549
  --model MODEL_NAME
519
550
  --workspace PATH
520
551
  --allow-commands
552
+ --allow-writes
521
553
  --max-steps 12
522
554
  --temperature 0.2
523
555
  --ollama-base-url http://127.0.0.1:11434
package/src/config.js CHANGED
@@ -9,6 +9,7 @@ const DEFAULTS = {
9
9
  lmStudioBaseUrl: "http://127.0.0.1:1234",
10
10
  maxSteps: 12,
11
11
  allowCommands: false,
12
+ allowWrites: false,
12
13
  temperature: 0.2
13
14
  };
14
15
 
@@ -65,7 +66,8 @@ function readEnvConfig() {
65
66
  model: process.env.LOCAL_CODE_MODEL,
66
67
  ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
67
68
  lmStudioBaseUrl: process.env.LM_STUDIO_BASE_URL,
68
- allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS)
69
+ allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS),
70
+ allowWrites: parseBoolean(process.env.LOCAL_CODE_ALLOW_WRITES)
69
71
  };
70
72
  }
71
73
 
package/src/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { checkSyntax } from "./syntaxCheck.js";
2
- import { confirmCommand } from "./ui.js";
2
+ import { confirmCommand, confirmWrite } from "./ui.js";
3
3
 
4
4
  export function createToolset(workspace) {
5
5
  const tools = {
@@ -17,15 +17,22 @@ export function createToolset(workspace) {
17
17
  description: [
18
18
  "Create or overwrite a UTF-8 file inside the workspace.",
19
19
  "For files longer than ~40 lines, write a small skeleton first (imports and function signatures), then use append_file to add the rest in smaller pieces.",
20
- "Do not use this to add to a file that already has content you want to keep - use append_file instead, so you never have to retype existing content."
20
+ "Do not use this to add to a file that already has content you want to keep - use append_file instead, so you never have to retype existing content.",
21
+ "Unless the session was started with --allow-writes, the user is asked to approve each file change in the terminal before it is written."
21
22
  ].join(" "),
22
23
  args: { path: "string", content: "string" },
23
- run: async ({ path, content }) => withSyntaxCheck(workspace, path, () => workspace.writeFile(path, content))
24
+ run: async ({ path, content }) => {
25
+ const approved = await approveWrite(workspace, { action: "write", path, preview: content });
26
+ return withSyntaxCheck(workspace, path, () => workspace.writeFile(path, content, { approved }));
27
+ }
24
28
  },
25
29
  append_file: {
26
30
  description: "Append content to the end of a file, creating it if it does not exist. Prefer this over write_file when a file already has content you want to keep.",
27
31
  args: { path: "string", content: "string" },
28
- run: async ({ path, content }) => withSyntaxCheck(workspace, path, () => workspace.appendFile(path, content))
32
+ run: async ({ path, content }) => {
33
+ const approved = await approveWrite(workspace, { action: "append", path, preview: content });
34
+ return withSyntaxCheck(workspace, path, () => workspace.appendFile(path, content, { approved }));
35
+ }
29
36
  },
30
37
  replace_in_file: {
31
38
  description: "Replace text inside an existing file.",
@@ -35,11 +42,18 @@ export function createToolset(workspace) {
35
42
  replaceText: "string",
36
43
  replaceAll: "boolean?"
37
44
  },
38
- run: async (args) => withSyntaxCheck(
39
- workspace,
40
- args.path,
41
- () => workspace.replaceInFile(args.path, args.findText, args.replaceText, args.replaceAll)
42
- )
45
+ run: async (args) => {
46
+ const approved = await approveWrite(workspace, {
47
+ action: "replace",
48
+ path: args.path,
49
+ preview: `Find:\n${args.findText}\nReplace with:\n${args.replaceText}`
50
+ });
51
+ return withSyntaxCheck(
52
+ workspace,
53
+ args.path,
54
+ () => workspace.replaceInFile(args.path, args.findText, args.replaceText, args.replaceAll, { approved })
55
+ );
56
+ }
43
57
  },
44
58
  search_text: {
45
59
  description: "Search for a plain text query across workspace files.",
@@ -49,7 +63,10 @@ export function createToolset(workspace) {
49
63
  make_directory: {
50
64
  description: "Create a directory recursively in the workspace.",
51
65
  args: { path: "string" },
52
- run: async ({ path }) => workspace.makeDirectory(path)
66
+ run: async ({ path }) => {
67
+ const approved = await approveWrite(workspace, { action: "mkdir", path });
68
+ return workspace.makeDirectory(path, { approved });
69
+ }
53
70
  },
54
71
  run_command: {
55
72
  description: "Run a local shell command inside the workspace (e.g. dotnet run, npm test, python script.py) to build, test, or execute code. Unless the session was started with --allow-commands, the user is asked to approve each command in the terminal before it runs.",
@@ -89,6 +106,14 @@ export function createToolset(workspace) {
89
106
  };
90
107
  }
91
108
 
109
+ async function approveWrite(workspace, { action, path, preview }) {
110
+ if (workspace.allowWrites) {
111
+ return true;
112
+ }
113
+
114
+ return confirmWrite({ action, path, preview, cwd: workspace.rootPath });
115
+ }
116
+
92
117
  async function withSyntaxCheck(workspace, targetPath, writeAction) {
93
118
  const message = await writeAction();
94
119
  const result = await checkSyntax(workspace.resolvePath(targetPath));
package/src/ui.js CHANGED
@@ -174,6 +174,38 @@ export async function confirmCommand({ command, args = [], cwd }) {
174
174
  }
175
175
  }
176
176
 
177
+ const WRITE_ACTION_LABELS = {
178
+ write: "create/overwrite file",
179
+ append: "append to file",
180
+ replace: "replace text in file",
181
+ mkdir: "create directory"
182
+ };
183
+
184
+ export async function confirmWrite({ action, path: targetPath, preview = "", cwd }) {
185
+ if (!isInteractive()) {
186
+ return false;
187
+ }
188
+
189
+ const label = WRITE_ACTION_LABELS[action] ?? action;
190
+ output.write(`\n${style("Model wants to " + label + ":", "yellow")} ${targetPath}\n`);
191
+ if (cwd) {
192
+ output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
193
+ }
194
+
195
+ if (preview) {
196
+ const snippet = preview.length > 400 ? `${preview.slice(0, 400)}...` : preview;
197
+ output.write(`${style("Preview:", "dim")}\n${snippet}\n`);
198
+ }
199
+
200
+ const rl = readlinePromises.createInterface({ input, output });
201
+ try {
202
+ const answer = (await rl.question("Allow this change? [y/N]: ")).trim().toLowerCase();
203
+ return answer === "y" || answer === "yes";
204
+ } finally {
205
+ rl.close();
206
+ }
207
+ }
208
+
177
209
  export function printNote(message) {
178
210
  console.log(style(message, "dim"));
179
211
  }
package/src/workspace.js CHANGED
@@ -12,6 +12,13 @@ export class Workspace {
12
12
  constructor(rootPath, options = {}) {
13
13
  this.rootPath = path.resolve(rootPath);
14
14
  this.allowCommands = Boolean(options.allowCommands);
15
+ this.allowWrites = Boolean(options.allowWrites);
16
+ }
17
+
18
+ assertWriteApproved(approved) {
19
+ if (!this.allowWrites && !approved) {
20
+ throw new Error("File change was not approved. Re-run with --allow-writes to skip the prompt, or approve it when asked.");
21
+ }
15
22
  }
16
23
 
17
24
  resolvePath(targetPath = ".") {
@@ -47,21 +54,24 @@ export class Workspace {
47
54
  return fs.readFile(fullPath, "utf8");
48
55
  }
49
56
 
50
- async writeFile(targetPath, content) {
57
+ async writeFile(targetPath, content, { approved = false } = {}) {
58
+ this.assertWriteApproved(approved);
51
59
  const fullPath = this.resolvePath(targetPath);
52
60
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
53
61
  await fs.writeFile(fullPath, content, "utf8");
54
62
  return `Wrote ${path.relative(this.rootPath, fullPath)}`;
55
63
  }
56
64
 
57
- async appendFile(targetPath, content) {
65
+ async appendFile(targetPath, content, { approved = false } = {}) {
66
+ this.assertWriteApproved(approved);
58
67
  const fullPath = this.resolvePath(targetPath);
59
68
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
60
69
  await fs.appendFile(fullPath, content, "utf8");
61
70
  return `Appended to ${path.relative(this.rootPath, fullPath)}`;
62
71
  }
63
72
 
64
- async replaceInFile(targetPath, findText, replaceText, replaceAll = false) {
73
+ async replaceInFile(targetPath, findText, replaceText, replaceAll = false, { approved = false } = {}) {
74
+ this.assertWriteApproved(approved);
65
75
  const fullPath = this.resolvePath(targetPath);
66
76
  const current = await fs.readFile(fullPath, "utf8");
67
77
  if (!current.includes(findText)) {
@@ -102,7 +112,8 @@ export class Workspace {
102
112
  return matches;
103
113
  }
104
114
 
105
- async makeDirectory(targetPath) {
115
+ async makeDirectory(targetPath, { approved = false } = {}) {
116
+ this.assertWriteApproved(approved);
106
117
  const fullPath = this.resolvePath(targetPath);
107
118
  await fs.mkdir(fullPath, { recursive: true });
108
119
  return `Created ${path.relative(this.rootPath, fullPath)}`;