linkshell-cli 0.3.12 → 0.3.13

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.
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, join, relative } from "node:path";
4
5
  import {
@@ -835,6 +836,30 @@ interface ProviderRuntimeCapabilities {
835
836
  const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
836
837
  const CLAUDE_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
837
838
  const AGENT_PERMISSION_MODES: AgentPermissionMode[] = ["read_only", "workspace_write", "full_access"];
839
+ const COMMAND_OUTPUT_MAX_BYTES = 96 * 1024;
840
+ const LINKSHELL_NATIVE_COMMANDS: Array<{
841
+ name: string;
842
+ description: string;
843
+ category: string;
844
+ argsMode?: AgentCommandDescriptor["argsMode"];
845
+ destructive?: boolean;
846
+ providers?: AgentProvider[];
847
+ }> = [
848
+ { name: "status", description: "Show current Agent and workspace status", category: "LinkShell", argsMode: "none" },
849
+ { name: "plan", description: "Enter Plan mode for the next turn", category: "Agent", argsMode: "none" },
850
+ { name: "exit-plan", description: "Exit Plan mode", category: "Agent", argsMode: "none" },
851
+ { name: "review", description: "Ask the Agent to review current local changes", category: "Agent", argsMode: "optional" },
852
+ { name: "subagents", description: "Ask the Agent to split work across subagents when useful", category: "Agent", argsMode: "optional" },
853
+ { name: "compact", description: "Compact the active Codex context", category: "Codex", argsMode: "none", providers: ["codex"] },
854
+ { name: "clear", description: "Start a fresh Agent context for this conversation", category: "Agent", argsMode: "none", destructive: true },
855
+ { name: "git-status", description: "Show branch and working tree status", category: "Git", argsMode: "none" },
856
+ { name: "git-diff", description: "Show a compact diffstat for current changes", category: "Git", argsMode: "none" },
857
+ { name: "git-commit", description: "Commit staged changes with the given message", category: "Git", argsMode: "required" },
858
+ { name: "git-pull", description: "Pull with fast-forward only", category: "Git", argsMode: "none" },
859
+ { name: "git-push", description: "Push the current branch", category: "Git", argsMode: "none" },
860
+ { name: "git-stash", description: "Stash current working tree changes", category: "Git", argsMode: "optional" },
861
+ { name: "git-stash-pop", description: "Pop the latest stash", category: "Git", argsMode: "none" },
862
+ ];
838
863
  const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
839
864
  "add-dir",
840
865
  "agents",
@@ -1107,17 +1132,30 @@ function customClaudeCommands(cwd: string): AgentCommandDescriptor[] {
1107
1132
 
1108
1133
  function defaultProviderCommands(provider: AgentProvider, cwd: string, enabled: boolean): AgentCommandDescriptor[] {
1109
1134
  const disabledReason = enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`;
1135
+ const linkshellCommands = LINKSHELL_NATIVE_COMMANDS
1136
+ .filter((command) => !command.providers || command.providers.includes(provider))
1137
+ .map((command) => makeCommand({
1138
+ provider,
1139
+ name: command.name,
1140
+ description: command.description,
1141
+ source: "linkshell",
1142
+ category: command.category,
1143
+ argsMode: command.argsMode ?? "optional",
1144
+ destructive: command.destructive,
1145
+ disabledReason,
1146
+ executionKind: "native",
1147
+ }));
1110
1148
  if (provider === "codex") {
1111
- return [];
1149
+ return linkshellCommands;
1112
1150
  }
1113
1151
  if (provider === "claude") {
1114
1152
  const custom = customClaudeCommands(cwd).map((command) => ({
1115
1153
  ...command,
1116
1154
  disabledReason: command.disabledReason ?? disabledReason,
1117
1155
  }));
1118
- return custom;
1156
+ return [...linkshellCommands, ...custom];
1119
1157
  }
1120
- return [];
1158
+ return linkshellCommands;
1121
1159
  }
1122
1160
 
1123
1161
  function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>): AgentCommandDescriptor[] {
@@ -1136,6 +1174,81 @@ function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>):
1136
1174
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
1137
1175
  }
1138
1176
 
1177
+ function isGitNativeCommand(name: string): boolean {
1178
+ return name === "git-status" ||
1179
+ name === "git-diff" ||
1180
+ name === "git-commit" ||
1181
+ name === "git-pull" ||
1182
+ name === "git-push" ||
1183
+ name === "git-stash" ||
1184
+ name === "git-stash-pop";
1185
+ }
1186
+
1187
+ function gitCommandArgs(name: string, args?: string): { display: string; argv: string[] } {
1188
+ const message = args?.trim();
1189
+ switch (name) {
1190
+ case "git-status":
1191
+ return { display: "git status --short --branch", argv: ["status", "--short", "--branch"] };
1192
+ case "git-diff":
1193
+ return { display: "git diff --stat", argv: ["diff", "--stat"] };
1194
+ case "git-commit":
1195
+ if (!message) throw new Error("请先输入提交信息,例如 /git-commit fix mobile agent timeline");
1196
+ return { display: `git commit -m ${JSON.stringify(message)}`, argv: ["commit", "-m", message] };
1197
+ case "git-pull":
1198
+ return { display: "git pull --ff-only", argv: ["pull", "--ff-only"] };
1199
+ case "git-push":
1200
+ return { display: "git push", argv: ["push"] };
1201
+ case "git-stash":
1202
+ return {
1203
+ display: "git stash push -u",
1204
+ argv: ["stash", "push", "-u", "-m", message || "LinkShell mobile stash"],
1205
+ };
1206
+ case "git-stash-pop":
1207
+ return { display: "git stash pop", argv: ["stash", "pop"] };
1208
+ default:
1209
+ throw new Error(`未知 Git 命令:/${name}`);
1210
+ }
1211
+ }
1212
+
1213
+ function runProcess(
1214
+ command: string,
1215
+ args: string[],
1216
+ options: { cwd: string; maxBytes?: number },
1217
+ ): Promise<{ output: string; exitCode: number | null; signal: NodeJS.Signals | null }> {
1218
+ return new Promise((resolve, reject) => {
1219
+ const child = spawn(command, args, {
1220
+ cwd: options.cwd,
1221
+ stdio: ["ignore", "pipe", "pipe"],
1222
+ windowsHide: true,
1223
+ });
1224
+ const maxBytes = options.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES;
1225
+ let output = "";
1226
+ let bytes = 0;
1227
+ let truncated = false;
1228
+ const append = (chunk: Buffer) => {
1229
+ if (bytes >= maxBytes) {
1230
+ truncated = true;
1231
+ return;
1232
+ }
1233
+ const remaining = maxBytes - bytes;
1234
+ const slice = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
1235
+ output += slice.toString("utf8");
1236
+ bytes += slice.byteLength;
1237
+ if (slice.byteLength < chunk.byteLength) truncated = true;
1238
+ };
1239
+ child.stdout.on("data", append);
1240
+ child.stderr.on("data", append);
1241
+ child.once("error", reject);
1242
+ child.once("close", (exitCode, signal) => {
1243
+ resolve({
1244
+ output: `${output.trimEnd()}${truncated ? "\n\n[truncated by LinkShell]" : ""}`,
1245
+ exitCode,
1246
+ signal,
1247
+ });
1248
+ });
1249
+ });
1250
+ }
1251
+
1139
1252
  function runtimeCommands(provider: AgentProvider, value: unknown): AgentCommandDescriptor[] {
1140
1253
  const raw = asRecord(value);
1141
1254
  const commandsValue =
@@ -1985,14 +2098,8 @@ export class AgentWorkspaceProxy {
1985
2098
  return;
1986
2099
  }
1987
2100
 
1988
- if (conversation.provider !== "codex") {
1989
- this.addItem(conversation.id, {
1990
- id: id("error"),
1991
- conversationId: conversation.id,
1992
- type: "error",
1993
- error: `${command.title} 暂无 ${providerLabel(conversation.provider)} 原生实现。`,
1994
- createdAt: now,
1995
- });
2101
+ if (isGitNativeCommand(command.name)) {
2102
+ await this.executeGitNativeCommand(conversation, command.name, args);
1996
2103
  return;
1997
2104
  }
1998
2105
 
@@ -2009,6 +2116,22 @@ export class AgentWorkspaceProxy {
2009
2116
  return;
2010
2117
  }
2011
2118
 
2119
+ if (command.name === "review" || command.name === "subagents") {
2120
+ const prompt = command.name === "review"
2121
+ ? args || "Review the current local changes."
2122
+ : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
2123
+ await this.sendPrompt({
2124
+ conversationId: conversation.id,
2125
+ clientMessageId: id(command.name),
2126
+ contentBlocks: [{ type: "text", text: prompt }],
2127
+ model: conversation.model,
2128
+ reasoningEffort: conversation.reasoningEffort,
2129
+ permissionMode: conversation.permissionMode,
2130
+ collaborationMode: conversation.collaborationMode,
2131
+ });
2132
+ return;
2133
+ }
2134
+
2012
2135
  if (command.name === "compact") {
2013
2136
  if (!(client instanceof AcpClient)) throw new Error("当前 Codex runtime 不支持原生 compact。");
2014
2137
  conversation.status = "running";
@@ -2046,22 +2169,6 @@ export class AgentWorkspaceProxy {
2046
2169
  return;
2047
2170
  }
2048
2171
 
2049
- if (command.name === "review" || command.name === "subagents") {
2050
- const prompt = command.name === "review"
2051
- ? args || "Review the current local changes."
2052
- : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
2053
- await this.sendPrompt({
2054
- conversationId: conversation.id,
2055
- clientMessageId: id(command.name),
2056
- contentBlocks: [{ type: "text", text: prompt }],
2057
- model: conversation.model,
2058
- reasoningEffort: conversation.reasoningEffort,
2059
- permissionMode: conversation.permissionMode,
2060
- collaborationMode: conversation.collaborationMode,
2061
- });
2062
- return;
2063
- }
2064
-
2065
2172
  throw new Error(`命令暂未实现:/${command.name}`);
2066
2173
  } catch (error) {
2067
2174
  const message = error instanceof Error ? error.message : String(error);
@@ -2076,6 +2183,46 @@ export class AgentWorkspaceProxy {
2076
2183
  }
2077
2184
  }
2078
2185
 
2186
+ private async executeGitNativeCommand(
2187
+ conversation: AgentConversation,
2188
+ commandName: string,
2189
+ args?: string,
2190
+ ): Promise<void> {
2191
+ const git = gitCommandArgs(commandName, args);
2192
+ const toolId = id(commandName);
2193
+ const now = Date.now();
2194
+ conversation.status = "running";
2195
+ conversation.lastMessagePreview = git.display;
2196
+ conversation.lastActivityAt = now;
2197
+ this.emitConversation(conversation);
2198
+ this.upsertTool(conversation.id, {
2199
+ id: toolId,
2200
+ name: "命令",
2201
+ input: `${git.display}\n\ncwd: ${conversation.cwd}`,
2202
+ createdAt: now,
2203
+ status: "running",
2204
+ });
2205
+ const result = await runProcess("git", git.argv, {
2206
+ cwd: conversation.cwd,
2207
+ maxBytes: COMMAND_OUTPUT_MAX_BYTES,
2208
+ });
2209
+ const ok = result.exitCode === 0;
2210
+ this.upsertTool(conversation.id, {
2211
+ id: toolId,
2212
+ name: "命令",
2213
+ input: `${git.display}\n\ncwd: ${conversation.cwd}`,
2214
+ output: result.output || (ok ? "完成" : `退出码 ${result.exitCode ?? "unknown"}`),
2215
+ createdAt: now,
2216
+ status: ok ? "completed" : "failed",
2217
+ });
2218
+ conversation.status = ok ? "idle" : "error";
2219
+ conversation.lastMessagePreview = ok
2220
+ ? `${git.display} 完成`
2221
+ : `${git.display} 失败`;
2222
+ conversation.lastActivityAt = Date.now();
2223
+ this.emitConversation(conversation);
2224
+ }
2225
+
2079
2226
  private handleRequest(method: string, params: unknown): Promise<unknown> | unknown {
2080
2227
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
2081
2228
  return this.handleStructuredInput(params, true);