helloloop 0.1.1 → 0.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/.claude-plugin/plugin.json +14 -0
- package/.codex-plugin/plugin.json +6 -6
- package/LICENSE +176 -0
- package/README.md +323 -146
- package/hosts/claude/marketplace/.claude-plugin/marketplace.json +14 -0
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +9 -0
- package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +28 -0
- package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +38 -0
- package/hosts/gemini/extension/GEMINI.md +29 -0
- package/hosts/gemini/extension/commands/helloloop.toml +24 -0
- package/hosts/gemini/extension/gemini-extension.json +13 -0
- package/package.json +9 -3
- package/skills/helloloop/SKILL.md +59 -31
- package/src/analyze_confirmation.mjs +128 -0
- package/src/analyze_prompt.mjs +75 -0
- package/src/analyzer.mjs +208 -0
- package/src/cli.mjs +148 -91
- package/src/cli_support.mjs +307 -0
- package/src/discovery.mjs +155 -0
- package/src/discovery_inference.mjs +220 -0
- package/src/discovery_paths.mjs +166 -0
- package/src/guardrails.mjs +59 -0
- package/src/install.mjs +142 -28
- package/src/process.mjs +31 -129
- package/src/prompt.mjs +10 -12
- package/src/shell_invocation.mjs +225 -0
- package/templates/analysis-output.schema.json +144 -0
package/src/install.mjs
CHANGED
|
@@ -9,9 +9,12 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
9
9
|
const __dirname = path.dirname(__filename);
|
|
10
10
|
|
|
11
11
|
export const runtimeBundleEntries = [
|
|
12
|
+
".claude-plugin",
|
|
12
13
|
".codex-plugin",
|
|
14
|
+
"LICENSE",
|
|
13
15
|
"README.md",
|
|
14
16
|
"bin",
|
|
17
|
+
"hosts",
|
|
15
18
|
"package.json",
|
|
16
19
|
"scripts",
|
|
17
20
|
"skills",
|
|
@@ -19,8 +22,15 @@ export const runtimeBundleEntries = [
|
|
|
19
22
|
"templates",
|
|
20
23
|
];
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const codexBundleEntries = runtimeBundleEntries.filter((entry) => ![
|
|
26
|
+
".claude-plugin",
|
|
27
|
+
"hosts",
|
|
28
|
+
].includes(entry));
|
|
29
|
+
|
|
30
|
+
const supportedHosts = ["codex", "claude", "gemini"];
|
|
31
|
+
|
|
32
|
+
function resolveHomeDir(homeDir, defaultDirName) {
|
|
33
|
+
return path.resolve(homeDir || path.join(os.homedir(), defaultDirName));
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
function assertPathInside(parentDir, targetDir, label) {
|
|
@@ -30,21 +40,38 @@ function assertPathInside(parentDir, targetDir, label) {
|
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
function
|
|
34
|
-
|
|
43
|
+
function removeTargetIfNeeded(targetPath, force) {
|
|
44
|
+
if (!fileExists(targetPath)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!force) {
|
|
48
|
+
throw new Error(`目标目录已存在:${targetPath}。若要覆盖,请追加 --force。`);
|
|
49
|
+
}
|
|
50
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function copyBundleEntries(bundleRoot, targetRoot, entries) {
|
|
54
|
+
for (const entry of entries) {
|
|
35
55
|
const sourcePath = path.join(bundleRoot, entry);
|
|
36
56
|
if (!fileExists(sourcePath)) {
|
|
37
57
|
continue;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
fs.cpSync(sourcePath, path.join(
|
|
60
|
+
fs.cpSync(sourcePath, path.join(targetRoot, entry), {
|
|
41
61
|
force: true,
|
|
42
62
|
recursive: true,
|
|
43
63
|
});
|
|
44
64
|
}
|
|
45
65
|
}
|
|
46
66
|
|
|
47
|
-
function
|
|
67
|
+
function copyDirectory(sourceRoot, targetRoot) {
|
|
68
|
+
fs.cpSync(sourceRoot, targetRoot, {
|
|
69
|
+
force: true,
|
|
70
|
+
recursive: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function updateCodexMarketplace(marketplaceFile) {
|
|
48
75
|
const marketplace = fileExists(marketplaceFile)
|
|
49
76
|
? readJson(marketplaceFile)
|
|
50
77
|
: {
|
|
@@ -73,8 +100,6 @@ function updateMarketplace(marketplaceFile) {
|
|
|
73
100
|
category: "Coding",
|
|
74
101
|
};
|
|
75
102
|
|
|
76
|
-
marketplace.plugins = marketplace.plugins.filter((plugin) => plugin?.name !== "autoloop");
|
|
77
|
-
|
|
78
103
|
const existingIndex = marketplace.plugins.findIndex((plugin) => plugin?.name === "helloloop");
|
|
79
104
|
if (existingIndex >= 0) {
|
|
80
105
|
marketplace.plugins.splice(existingIndex, 1, nextEntry);
|
|
@@ -85,35 +110,40 @@ function updateMarketplace(marketplaceFile) {
|
|
|
85
110
|
writeJson(marketplaceFile, marketplace);
|
|
86
111
|
}
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
113
|
+
function updateClaudeSettings(settingsFile, marketplaceRoot) {
|
|
114
|
+
const settings = fileExists(settingsFile)
|
|
115
|
+
? readJson(settingsFile)
|
|
116
|
+
: {};
|
|
117
|
+
|
|
118
|
+
settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
|
|
119
|
+
settings.enabledPlugins = settings.enabledPlugins || {};
|
|
120
|
+
|
|
121
|
+
settings.extraKnownMarketplaces["helloloop-local"] = {
|
|
122
|
+
source: "directory",
|
|
123
|
+
path: marketplaceRoot.replaceAll("\\", "/"),
|
|
124
|
+
};
|
|
125
|
+
settings.enabledPlugins["helloloop@helloloop-local"] = true;
|
|
126
|
+
|
|
127
|
+
writeJson(settingsFile, settings);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function installCodexHost(bundleRoot, options) {
|
|
131
|
+
const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
|
|
91
132
|
const targetPluginsRoot = path.join(resolvedCodexHome, "plugins");
|
|
92
133
|
const targetPluginRoot = path.join(targetPluginsRoot, "helloloop");
|
|
93
|
-
const legacyPluginRoot = path.join(targetPluginsRoot, "autoloop");
|
|
94
134
|
const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
|
|
95
135
|
const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
|
|
96
136
|
|
|
97
137
|
if (!fileExists(manifestFile)) {
|
|
98
|
-
throw new Error(
|
|
138
|
+
throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
|
|
99
139
|
}
|
|
100
140
|
|
|
101
|
-
assertPathInside(resolvedCodexHome, targetPluginRoot, "目标插件目录");
|
|
102
|
-
|
|
103
|
-
if (fileExists(targetPluginRoot)) {
|
|
104
|
-
if (!options.force) {
|
|
105
|
-
throw new Error(`目标插件目录已存在:${targetPluginRoot}。若要覆盖,请追加 --force。`);
|
|
106
|
-
}
|
|
107
|
-
fs.rmSync(targetPluginRoot, { recursive: true, force: true });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (fileExists(legacyPluginRoot)) {
|
|
111
|
-
fs.rmSync(legacyPluginRoot, { recursive: true, force: true });
|
|
112
|
-
}
|
|
141
|
+
assertPathInside(resolvedCodexHome, targetPluginRoot, "Codex 目标插件目录");
|
|
142
|
+
removeTargetIfNeeded(targetPluginRoot, options.force);
|
|
113
143
|
|
|
114
144
|
ensureDir(targetPluginsRoot);
|
|
115
145
|
ensureDir(targetPluginRoot);
|
|
116
|
-
copyBundleEntries(bundleRoot, targetPluginRoot);
|
|
146
|
+
copyBundleEntries(bundleRoot, targetPluginRoot, codexBundleEntries);
|
|
117
147
|
|
|
118
148
|
const gitMetadataPath = path.join(targetPluginRoot, ".git");
|
|
119
149
|
if (fileExists(gitMetadataPath)) {
|
|
@@ -121,10 +151,94 @@ export function installPluginBundle(options = {}) {
|
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
ensureDir(path.dirname(marketplaceFile));
|
|
124
|
-
|
|
154
|
+
updateCodexMarketplace(marketplaceFile);
|
|
125
155
|
|
|
126
156
|
return {
|
|
127
|
-
|
|
157
|
+
host: "codex",
|
|
158
|
+
displayName: "Codex",
|
|
159
|
+
targetRoot: targetPluginRoot,
|
|
128
160
|
marketplaceFile,
|
|
129
161
|
};
|
|
130
162
|
}
|
|
163
|
+
|
|
164
|
+
function installClaudeHost(bundleRoot, options) {
|
|
165
|
+
const resolvedClaudeHome = resolveHomeDir(options.claudeHome, ".claude");
|
|
166
|
+
const sourceMarketplaceRoot = path.join(bundleRoot, "hosts", "claude", "marketplace");
|
|
167
|
+
const sourceManifest = path.join(bundleRoot, ".claude-plugin", "plugin.json");
|
|
168
|
+
const targetMarketplaceRoot = path.join(resolvedClaudeHome, "marketplaces", "helloloop-local");
|
|
169
|
+
const settingsFile = path.join(resolvedClaudeHome, "settings.json");
|
|
170
|
+
|
|
171
|
+
if (!fileExists(sourceManifest)) {
|
|
172
|
+
throw new Error(`未找到 Claude 插件 manifest:${sourceManifest}`);
|
|
173
|
+
}
|
|
174
|
+
if (!fileExists(path.join(sourceMarketplaceRoot, ".claude-plugin", "marketplace.json"))) {
|
|
175
|
+
throw new Error(`未找到 Claude marketplace 模板:${sourceMarketplaceRoot}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
assertPathInside(resolvedClaudeHome, targetMarketplaceRoot, "Claude marketplace 目录");
|
|
179
|
+
removeTargetIfNeeded(targetMarketplaceRoot, options.force);
|
|
180
|
+
|
|
181
|
+
ensureDir(path.dirname(targetMarketplaceRoot));
|
|
182
|
+
copyDirectory(sourceMarketplaceRoot, targetMarketplaceRoot);
|
|
183
|
+
updateClaudeSettings(settingsFile, targetMarketplaceRoot);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
host: "claude",
|
|
187
|
+
displayName: "Claude",
|
|
188
|
+
targetRoot: path.join(targetMarketplaceRoot, "plugins", "helloloop"),
|
|
189
|
+
marketplaceFile: path.join(targetMarketplaceRoot, ".claude-plugin", "marketplace.json"),
|
|
190
|
+
settingsFile,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function installGeminiHost(bundleRoot, options) {
|
|
195
|
+
const resolvedGeminiHome = resolveHomeDir(options.geminiHome, ".gemini");
|
|
196
|
+
const sourceExtensionRoot = path.join(bundleRoot, "hosts", "gemini", "extension");
|
|
197
|
+
const targetExtensionRoot = path.join(resolvedGeminiHome, "extensions", "helloloop");
|
|
198
|
+
|
|
199
|
+
if (!fileExists(path.join(sourceExtensionRoot, "gemini-extension.json"))) {
|
|
200
|
+
throw new Error(`未找到 Gemini 扩展清单:${sourceExtensionRoot}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
assertPathInside(resolvedGeminiHome, targetExtensionRoot, "Gemini 扩展目录");
|
|
204
|
+
removeTargetIfNeeded(targetExtensionRoot, options.force);
|
|
205
|
+
|
|
206
|
+
ensureDir(path.dirname(targetExtensionRoot));
|
|
207
|
+
copyDirectory(sourceExtensionRoot, targetExtensionRoot);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
host: "gemini",
|
|
211
|
+
displayName: "Gemini",
|
|
212
|
+
targetRoot: targetExtensionRoot,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveInstallHosts(hostOption) {
|
|
217
|
+
const normalized = String(hostOption || "codex").trim().toLowerCase();
|
|
218
|
+
if (normalized === "all") {
|
|
219
|
+
return [...supportedHosts];
|
|
220
|
+
}
|
|
221
|
+
if (!supportedHosts.includes(normalized)) {
|
|
222
|
+
throw new Error(`不支持的宿主:${hostOption}。可选值:codex、claude、gemini、all`);
|
|
223
|
+
}
|
|
224
|
+
return [normalized];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function installPluginBundle(options = {}) {
|
|
228
|
+
const bundleRoot = path.resolve(options.bundleRoot || path.join(__dirname, ".."));
|
|
229
|
+
const selectedHosts = resolveInstallHosts(options.host);
|
|
230
|
+
const installers = {
|
|
231
|
+
codex: () => installCodexHost(bundleRoot, options),
|
|
232
|
+
claude: () => installClaudeHost(bundleRoot, options),
|
|
233
|
+
gemini: () => installGeminiHost(bundleRoot, options),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const installedHosts = selectedHosts.map((host) => installers[host]());
|
|
237
|
+
const codexResult = installedHosts.find((item) => item.host === "codex");
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
installedHosts,
|
|
241
|
+
targetPluginRoot: codexResult?.targetRoot || "",
|
|
242
|
+
marketplaceFile: codexResult?.marketplaceFile || "",
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/process.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { spawn
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
4
|
|
|
5
5
|
import { ensureDir, nowIso, tailText, writeText } from "./common.mjs";
|
|
6
|
+
import { resolveCodexInvocation, resolveVerifyShellInvocation } from "./shell_invocation.mjs";
|
|
6
7
|
|
|
7
8
|
function runChild(command, args, options = {}) {
|
|
8
9
|
return new Promise((resolve) => {
|
|
@@ -51,130 +52,6 @@ function runChild(command, args, options = {}) {
|
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function quoteForCmd(value) {
|
|
55
|
-
const normalized = String(value ?? "");
|
|
56
|
-
if (!normalized.length) {
|
|
57
|
-
return "\"\"";
|
|
58
|
-
}
|
|
59
|
-
if (!/[\s"&<>|^]/.test(normalized)) {
|
|
60
|
-
return normalized;
|
|
61
|
-
}
|
|
62
|
-
return `"${normalized.replace(/"/g, "\"\"")}"`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function buildCmdCommandLine(executable, args) {
|
|
66
|
-
return [quoteForCmd(executable), ...args.map((item) => quoteForCmd(item))].join(" ");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function hasCommand(command, platform = process.platform) {
|
|
70
|
-
if (platform === "win32") {
|
|
71
|
-
const result = spawnSync("where.exe", [command], {
|
|
72
|
-
encoding: "utf8",
|
|
73
|
-
shell: false,
|
|
74
|
-
});
|
|
75
|
-
return result.status === 0;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const result = spawnSync("sh", ["-lc", `command -v ${command}`], {
|
|
79
|
-
encoding: "utf8",
|
|
80
|
-
shell: false,
|
|
81
|
-
});
|
|
82
|
-
return result.status === 0;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function resolveVerifyShellInvocation(options = {}) {
|
|
86
|
-
const platform = options.platform || process.platform;
|
|
87
|
-
const commandExists = options.commandExists || ((command) => hasCommand(command, platform));
|
|
88
|
-
|
|
89
|
-
if (platform === "win32") {
|
|
90
|
-
if (commandExists("pwsh")) {
|
|
91
|
-
return {
|
|
92
|
-
command: "pwsh",
|
|
93
|
-
argsPrefix: ["-NoLogo", "-NoProfile", "-Command"],
|
|
94
|
-
shell: false,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (commandExists("powershell")) {
|
|
99
|
-
return {
|
|
100
|
-
command: "powershell",
|
|
101
|
-
argsPrefix: ["-NoLogo", "-NoProfile", "-Command"],
|
|
102
|
-
shell: false,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
command: "cmd.exe",
|
|
108
|
-
argsPrefix: ["/d", "/s", "/c"],
|
|
109
|
-
shell: false,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
command: "sh",
|
|
115
|
-
argsPrefix: ["-lc"],
|
|
116
|
-
shell: false,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function resolveCodexExecutable(explicitExecutable = "") {
|
|
121
|
-
if (explicitExecutable) {
|
|
122
|
-
return explicitExecutable;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (process.platform !== "win32") {
|
|
126
|
-
return "codex";
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const candidates = ["codex.cmd", "codex", "codex.ps1"];
|
|
130
|
-
for (const candidate of candidates) {
|
|
131
|
-
const result = spawnSync("where.exe", [candidate], {
|
|
132
|
-
encoding: "utf8",
|
|
133
|
-
shell: false,
|
|
134
|
-
});
|
|
135
|
-
if (result.status !== 0) {
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const firstLine = String(result.stdout || "")
|
|
140
|
-
.split(/\r?\n/)
|
|
141
|
-
.map((line) => line.trim())
|
|
142
|
-
.find(Boolean);
|
|
143
|
-
if (firstLine) {
|
|
144
|
-
return firstLine;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return "codex";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function resolveCodexInvocation(explicitExecutable = "") {
|
|
152
|
-
const executable = resolveCodexExecutable(explicitExecutable);
|
|
153
|
-
|
|
154
|
-
if (process.platform === "win32" && /\.ps1$/i.test(executable)) {
|
|
155
|
-
return {
|
|
156
|
-
command: "pwsh",
|
|
157
|
-
argsPrefix: ["-NoLogo", "-NoProfile", "-File", executable],
|
|
158
|
-
shell: false,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(executable)) {
|
|
163
|
-
return {
|
|
164
|
-
command: "cmd.exe",
|
|
165
|
-
argsPrefix: ["/d", "/s", "/c"],
|
|
166
|
-
executable,
|
|
167
|
-
shell: false,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
command: executable,
|
|
173
|
-
argsPrefix: [],
|
|
174
|
-
shell: false,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
55
|
function writeCodexRunArtifacts(runDir, prefix, result, finalMessage) {
|
|
179
56
|
writeText(path.join(runDir, `${prefix}-stdout.log`), result.stdout);
|
|
180
57
|
writeText(path.join(runDir, `${prefix}-stderr.log`), result.stderr);
|
|
@@ -205,7 +82,7 @@ export async function runCodexTask({
|
|
|
205
82
|
ensureDir(runDir);
|
|
206
83
|
|
|
207
84
|
const lastMessageFile = path.join(runDir, `${outputPrefix}-last-message.txt`);
|
|
208
|
-
const invocation = resolveCodexInvocation(executable);
|
|
85
|
+
const invocation = resolveCodexInvocation({ explicitExecutable: executable });
|
|
209
86
|
const codexArgs = ["exec", "-C", context.repoRoot];
|
|
210
87
|
|
|
211
88
|
if (model) {
|
|
@@ -230,9 +107,19 @@ export async function runCodexTask({
|
|
|
230
107
|
}
|
|
231
108
|
codexArgs.push("-o", lastMessageFile, "-");
|
|
232
109
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
110
|
+
if (invocation.error) {
|
|
111
|
+
const result = {
|
|
112
|
+
ok: false,
|
|
113
|
+
code: 1,
|
|
114
|
+
stdout: "",
|
|
115
|
+
stderr: invocation.error,
|
|
116
|
+
};
|
|
117
|
+
writeText(path.join(runDir, `${outputPrefix}-prompt.md`), prompt);
|
|
118
|
+
writeCodexRunArtifacts(runDir, outputPrefix, result, "");
|
|
119
|
+
return { ...result, finalMessage: "" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const args = [...invocation.argsPrefix, ...codexArgs];
|
|
236
123
|
|
|
237
124
|
const result = await runChild(invocation.command, args, {
|
|
238
125
|
cwd: context.repoRoot,
|
|
@@ -266,6 +153,21 @@ export async function runCodexExec({ context, prompt, runDir, policy }) {
|
|
|
266
153
|
|
|
267
154
|
export async function runShellCommand(context, commandLine, runDir, index) {
|
|
268
155
|
const shellInvocation = resolveVerifyShellInvocation();
|
|
156
|
+
if (shellInvocation.error) {
|
|
157
|
+
const result = {
|
|
158
|
+
command: commandLine,
|
|
159
|
+
ok: false,
|
|
160
|
+
code: 1,
|
|
161
|
+
stdout: "",
|
|
162
|
+
stderr: shellInvocation.error,
|
|
163
|
+
};
|
|
164
|
+
const prefix = String(index + 1).padStart(2, "0");
|
|
165
|
+
writeText(path.join(runDir, `${prefix}-verify-command.txt`), commandLine);
|
|
166
|
+
writeText(path.join(runDir, `${prefix}-verify-stdout.log`), result.stdout);
|
|
167
|
+
writeText(path.join(runDir, `${prefix}-verify-stderr.log`), result.stderr);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
269
171
|
const result = await runChild(shellInvocation.command, [
|
|
270
172
|
...shellInvocation.argsPrefix,
|
|
271
173
|
commandLine,
|
package/src/prompt.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { formatList } from "./common.mjs";
|
|
2
|
+
import {
|
|
3
|
+
hasCustomProjectConstraints,
|
|
4
|
+
listMandatoryGuardrails,
|
|
5
|
+
resolveProjectConstraints,
|
|
6
|
+
} from "./guardrails.mjs";
|
|
2
7
|
|
|
3
8
|
function normalizeDocEntry(doc) {
|
|
4
9
|
return String(doc || "").trim().replaceAll("\\", "/");
|
|
@@ -63,17 +68,9 @@ export function buildTaskPrompt({
|
|
|
63
68
|
...requiredDocs,
|
|
64
69
|
...(task.docs || []),
|
|
65
70
|
]);
|
|
66
|
-
|
|
67
|
-
const effectiveConstraints = constraints
|
|
68
|
-
|
|
69
|
-
: [
|
|
70
|
-
"所有文件修改必须用 apply_patch。",
|
|
71
|
-
"不得通过压缩代码、删空行、缩短命名来压行数。",
|
|
72
|
-
"Rust / TS / TSX / Vue / Python / Go 单文件强制拆分上限 400 行。",
|
|
73
|
-
"新增可见文案必须同时补齐 zh-CN 与 en-US。",
|
|
74
|
-
"完成后必须同步相关中文文档与联调文档。",
|
|
75
|
-
"完成前必须主动运行验证,不要停在分析层。",
|
|
76
|
-
];
|
|
71
|
+
const mandatoryGuardrails = listMandatoryGuardrails();
|
|
72
|
+
const effectiveConstraints = resolveProjectConstraints(constraints);
|
|
73
|
+
const usingFallbackConstraints = !hasCustomProjectConstraints(constraints);
|
|
77
74
|
|
|
78
75
|
return [
|
|
79
76
|
"你在本地仓库中执行一个连续开发任务。",
|
|
@@ -95,7 +92,8 @@ export function buildTaskPrompt({
|
|
|
95
92
|
].join("\n")),
|
|
96
93
|
listSection("涉及路径", task.paths || []),
|
|
97
94
|
listSection("验收条件", task.acceptance || []),
|
|
98
|
-
listSection("
|
|
95
|
+
listSection("内建安全底线", mandatoryGuardrails),
|
|
96
|
+
listSection(usingFallbackConstraints ? "默认工程约束(文档未明确时生效)" : "项目/用户约束", effectiveConstraints),
|
|
99
97
|
repoStateText ? section("仓库当前状态", repoStateText) : "",
|
|
100
98
|
failureHistory.length
|
|
101
99
|
? section("已失败尝试摘要", failureHistory.slice(-4).map((item) => (
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function createUnavailableInvocation(message) {
|
|
4
|
+
return {
|
|
5
|
+
command: "",
|
|
6
|
+
argsPrefix: [],
|
|
7
|
+
shell: false,
|
|
8
|
+
error: message,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseWindowsCommandMatches(output) {
|
|
13
|
+
return String(output || "")
|
|
14
|
+
.split(/\r?\n/)
|
|
15
|
+
.map((line) => line.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasCommand(command, platform = process.platform) {
|
|
20
|
+
if (platform === "win32") {
|
|
21
|
+
const result = spawnSync("where.exe", [command], {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
shell: false,
|
|
24
|
+
});
|
|
25
|
+
return result.status === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = spawnSync("sh", ["-lc", `command -v ${command}`], {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
shell: false,
|
|
31
|
+
});
|
|
32
|
+
return result.status === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findWindowsCommandPaths(command, resolver) {
|
|
36
|
+
const lookup = resolver || ((name) => {
|
|
37
|
+
const result = spawnSync("where.exe", [name], {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
shell: false,
|
|
40
|
+
});
|
|
41
|
+
return result.status === 0 ? parseWindowsCommandMatches(result.stdout) : [];
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return lookup(command);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveWindowsPowerShellHost(options = {}) {
|
|
48
|
+
const commandExists = options.commandExists || ((command) => hasCommand(command, "win32"));
|
|
49
|
+
|
|
50
|
+
if (commandExists("pwsh")) {
|
|
51
|
+
return {
|
|
52
|
+
command: "pwsh",
|
|
53
|
+
shell: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (commandExists("powershell")) {
|
|
58
|
+
return {
|
|
59
|
+
command: "powershell",
|
|
60
|
+
shell: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveWindowsCommandShell(options = {}) {
|
|
68
|
+
const commandExists = options.commandExists || ((command) => hasCommand(command, "win32"));
|
|
69
|
+
const preferredHosts = [
|
|
70
|
+
{
|
|
71
|
+
check: "pwsh",
|
|
72
|
+
invocation: {
|
|
73
|
+
command: "pwsh",
|
|
74
|
+
argsPrefix: ["-NoLogo", "-NoProfile", "-Command"],
|
|
75
|
+
shell: false,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
check: "bash",
|
|
80
|
+
invocation: {
|
|
81
|
+
command: "bash",
|
|
82
|
+
argsPrefix: ["-lc"],
|
|
83
|
+
shell: false,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
check: "powershell",
|
|
88
|
+
invocation: {
|
|
89
|
+
command: "powershell",
|
|
90
|
+
argsPrefix: ["-NoLogo", "-NoProfile", "-Command"],
|
|
91
|
+
shell: false,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const host of preferredHosts) {
|
|
97
|
+
if (commandExists(host.check)) {
|
|
98
|
+
return host.invocation;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolvePosixCommandShell(options = {}) {
|
|
106
|
+
const platform = options.platform || process.platform;
|
|
107
|
+
const commandExists = options.commandExists || ((command) => hasCommand(command, platform));
|
|
108
|
+
|
|
109
|
+
if (commandExists("bash")) {
|
|
110
|
+
return {
|
|
111
|
+
command: "bash",
|
|
112
|
+
argsPrefix: ["-lc"],
|
|
113
|
+
shell: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
command: "sh",
|
|
119
|
+
argsPrefix: ["-lc"],
|
|
120
|
+
shell: false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isCmdLikeExecutable(executable) {
|
|
125
|
+
return /\.(cmd|bat)$/i.test(String(executable || ""));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveWindowsNamedExecutable(toolName, options = {}) {
|
|
129
|
+
const explicitExecutable = String(options.explicitExecutable || "").trim();
|
|
130
|
+
const findCommandPaths = options.findCommandPaths || ((command) => findWindowsCommandPaths(command));
|
|
131
|
+
|
|
132
|
+
if (explicitExecutable) {
|
|
133
|
+
return explicitExecutable;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const searchOrder = [`${toolName}.ps1`, `${toolName}.exe`, toolName];
|
|
137
|
+
for (const query of searchOrder) {
|
|
138
|
+
const safeMatch = findCommandPaths(query).find((candidate) => !isCmdLikeExecutable(candidate));
|
|
139
|
+
if (safeMatch) {
|
|
140
|
+
return safeMatch;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveVerifyShellInvocation(options = {}) {
|
|
148
|
+
const platform = options.platform || process.platform;
|
|
149
|
+
|
|
150
|
+
if (platform === "win32") {
|
|
151
|
+
const host = resolveWindowsCommandShell(options);
|
|
152
|
+
if (!host) {
|
|
153
|
+
return createUnavailableInvocation(
|
|
154
|
+
"Windows 环境需要 pwsh、bash(如 Git Bash)或 powershell 才能安全执行验证命令;HelloLoop 已禁止回退到 cmd.exe。",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return host;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return resolvePosixCommandShell(options);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function resolveCliInvocation(options = {}) {
|
|
164
|
+
const platform = options.platform || process.platform;
|
|
165
|
+
const explicitExecutable = String(options.explicitExecutable || "").trim();
|
|
166
|
+
const commandName = String(options.commandName || "").trim();
|
|
167
|
+
const toolDisplayName = String(options.toolDisplayName || commandName || "CLI");
|
|
168
|
+
|
|
169
|
+
if (!commandName && !explicitExecutable) {
|
|
170
|
+
return createUnavailableInvocation("未提供 CLI 名称或显式可执行路径。");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (platform !== "win32") {
|
|
174
|
+
return {
|
|
175
|
+
command: explicitExecutable || commandName,
|
|
176
|
+
argsPrefix: [],
|
|
177
|
+
shell: false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const executable = resolveWindowsNamedExecutable(commandName, {
|
|
182
|
+
explicitExecutable,
|
|
183
|
+
findCommandPaths: options.findCommandPaths,
|
|
184
|
+
});
|
|
185
|
+
if (!executable) {
|
|
186
|
+
return createUnavailableInvocation(
|
|
187
|
+
`未找到可安全执行的 ${toolDisplayName} 入口。Windows 环境需要 ${commandName}.ps1、${commandName}.exe 或其他非 cmd 的可执行入口。`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isCmdLikeExecutable(executable)) {
|
|
192
|
+
return createUnavailableInvocation(
|
|
193
|
+
`HelloLoop 在 Windows 已禁止通过 cmd/bat 启动 ${toolDisplayName}:${executable}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (/\.ps1$/i.test(executable)) {
|
|
198
|
+
const host = resolveWindowsPowerShellHost(options);
|
|
199
|
+
if (!host) {
|
|
200
|
+
return createUnavailableInvocation(
|
|
201
|
+
`需要 pwsh 或 powershell 才能安全执行 ${executable};HelloLoop 不会回退到 cmd.exe。`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
command: host.command,
|
|
207
|
+
argsPrefix: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", executable],
|
|
208
|
+
shell: host.shell,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
command: executable,
|
|
214
|
+
argsPrefix: [],
|
|
215
|
+
shell: false,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function resolveCodexInvocation(options = {}) {
|
|
220
|
+
return resolveCliInvocation({
|
|
221
|
+
...options,
|
|
222
|
+
commandName: "codex",
|
|
223
|
+
toolDisplayName: "Codex",
|
|
224
|
+
});
|
|
225
|
+
}
|