opencode-feature-factory 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/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
package/src/doctor.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { readJsoncConfig, readStrictJsonConfig } from "./config.js";
|
|
8
|
+
import { REDACTED_ENV_VALUE, collectEnv, resolvePluginConfig } from "./env-snapshot.js";
|
|
9
|
+
import { checkOpenTelemetryApiLoadability, evaluateContentCaptureRisk, sanitizeOtlpEnv } from "./telemetry.js";
|
|
10
|
+
import {
|
|
11
|
+
isSensitiveKey,
|
|
12
|
+
isSensitiveValue,
|
|
13
|
+
scrubSensitiveData,
|
|
14
|
+
scrubSensitiveString,
|
|
15
|
+
} from "./hardening/sensitive-data.js";
|
|
16
|
+
import { freeformSegment, projectFreeformData, renderTerminalSegments } from "./hardening/output-policy.js";
|
|
17
|
+
import { serializeTerminalJson } from "./hardening/terminal-encoding.js";
|
|
18
|
+
|
|
19
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
|
+
const SUBAGENTS = [
|
|
21
|
+
"backend-builder",
|
|
22
|
+
"codebase-researcher",
|
|
23
|
+
"design-interpreter",
|
|
24
|
+
"frontend-builder",
|
|
25
|
+
"implementation-validator",
|
|
26
|
+
"security-reviewer",
|
|
27
|
+
"spec-writer",
|
|
28
|
+
"story-reader",
|
|
29
|
+
"story-writer",
|
|
30
|
+
"test-verifier",
|
|
31
|
+
"work-decomposer",
|
|
32
|
+
"work-reviewer",
|
|
33
|
+
];
|
|
34
|
+
const EDIT_AGENTS = new Set(["feature-factory", "backend-builder", "frontend-builder", "test-verifier"]);
|
|
35
|
+
const NON_INTERACTIVE_ALLOW = ["read", "glob", "grep", "list", "bash", "webfetch", "task", "todowrite"];
|
|
36
|
+
const FACTORY_DENY = ["external_directory"];
|
|
37
|
+
const FEATURE_FACTORY_PLUGIN_SPECS = new Set(["opencode-feature-factory"]);
|
|
38
|
+
const COMPANION_TELEMETRY_PLUGIN_SPECS = new Set([
|
|
39
|
+
"@devtheops/opencode-plugin-otel",
|
|
40
|
+
"opencode-plugin-otel",
|
|
41
|
+
]);
|
|
42
|
+
const OTEL_ENDPOINT_KEYS = ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"];
|
|
43
|
+
const OTEL_HEADER_KEYS = ["OTEL_EXPORTER_OTLP_TRACES_HEADERS", "OTEL_EXPORTER_OTLP_HEADERS"];
|
|
44
|
+
const OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES";
|
|
45
|
+
const OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME";
|
|
46
|
+
|
|
47
|
+
export async function runDoctor(options = {}) {
|
|
48
|
+
const configPath = join(homedir(), ".config", "opencode", "opencode.jsonc");
|
|
49
|
+
const cfg = readOpencodeConfig(configPath);
|
|
50
|
+
const pluginSpec = options.local ? pathToFileURL(root).href : "opencode-feature-factory";
|
|
51
|
+
const pluginEntry = findPluginEntry(cfg, pluginSpec, options.local);
|
|
52
|
+
const pluginOptions = Array.isArray(pluginEntry) ? pluginEntry[1] || {} : {};
|
|
53
|
+
const registered = await resolvePluginConfig(pluginOptions);
|
|
54
|
+
const env = await collectEnv({ cwd: options.cwd, pluginSpec, pluginOptions });
|
|
55
|
+
const providers = providerAuthState();
|
|
56
|
+
const checks = [];
|
|
57
|
+
|
|
58
|
+
add(checks, "HOME", Boolean(process.env.HOME), process.env.HOME || "unset");
|
|
59
|
+
add(checks, "opencode config", existsSync(configPath), configPath);
|
|
60
|
+
add(checks, "plugin configured", Boolean(pluginEntry), pluginSpec);
|
|
61
|
+
add(checks, "profile config shape", staleProfileKeys(pluginOptions).length === 0, staleProfileKeys(pluginOptions).join(", ") || "profiles", "warn");
|
|
62
|
+
add(checks, "opencode CLI", env.capabilities.opencode, env.opencode_version || "opencode");
|
|
63
|
+
add(checks, "opencode run --command", env.capabilities.opencode_run_command, "opencode run --help");
|
|
64
|
+
add(checks, "opencode run --dir", env.capabilities.opencode_run_dir, "opencode run --help");
|
|
65
|
+
add(checks, "/feature command registered", Boolean(registered.command?.feature), "command.feature");
|
|
66
|
+
add(checks, "/feature command uses primary agent", registered.command?.feature?.agent === "feature-factory", registered.command?.feature?.agent || "unset");
|
|
67
|
+
add(checks, "feature-factory primary agent", Boolean(registered.agent?.["feature-factory"]), "agent.feature-factory");
|
|
68
|
+
add(checks, "12 subagents registered", missingSubagents(registered.agent).length === 0, missingSubagents(registered.agent).length ? `missing ${missingSubagents(registered.agent).join(", ")}` : "12 subagents");
|
|
69
|
+
add(checks, "factory permissions non-interactive", permissionFailures(registered.agent).length === 0, permissionFailures(registered.agent).join("; ") || "factory agent permissions");
|
|
70
|
+
add(checks, "feature skill path", Boolean(registered.skills?.paths?.length), registered.skills?.paths?.join(", ") || "none");
|
|
71
|
+
add(checks, "TUI sidebar export", hasTuiExport(), "package.json exports[\"./tui\"]", "warn");
|
|
72
|
+
add(checks, "repo-local feature skill", existsSync(join(options.cwd || process.cwd(), ".opencode", "skills", "feature", "SKILL.md")), ".opencode/skills/feature/SKILL.md", "warn");
|
|
73
|
+
add(checks, "repo-local feature schema", existsSync(join(options.cwd || process.cwd(), ".opencode", "skills", "feature", "SCHEMA.md")), ".opencode/skills/feature/SCHEMA.md", "warn");
|
|
74
|
+
add(checks, "git CLI", env.capabilities.git, "git");
|
|
75
|
+
add(checks, "git repository", env.capabilities.git_repo, options.cwd || process.cwd());
|
|
76
|
+
add(checks, "base branch", Boolean(env.capabilities.base_branch), env.capabilities.base_branch || "not detected");
|
|
77
|
+
add(checks, "gh CLI", env.capabilities.gh, "gh");
|
|
78
|
+
add(checks, "gh auth", env.capabilities.gh_auth, "gh auth status", "warn");
|
|
79
|
+
add(checks, ".opencode/factory ignored", env.capabilities.factory_gitignored === true, ".opencode/factory/", "warn");
|
|
80
|
+
add(checks, ".opencode/worktrees ignored", env.capabilities.worktrees_gitignored === true, ".opencode/worktrees/", "warn");
|
|
81
|
+
|
|
82
|
+
const telemetry = options.telemetry
|
|
83
|
+
? await collectTelemetryReadiness({ cfg, pluginOptions, env: process.env })
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
if (telemetry) addTelemetryChecks(checks, telemetry);
|
|
87
|
+
|
|
88
|
+
// Auth is per provider and smoke is per model; without dedup a dozen agents
|
|
89
|
+
// sharing one model would repeat the auth row and fire a dozen billable
|
|
90
|
+
// `opencode run` smoke calls.
|
|
91
|
+
const checkedProviders = new Set();
|
|
92
|
+
const smokedModels = new Set();
|
|
93
|
+
for (const [agent, model] of Object.entries(env.resolved_models)) {
|
|
94
|
+
if (!model) continue;
|
|
95
|
+
const provider = modelProvider(model);
|
|
96
|
+
add(checks, `model ${agent}`, Boolean(provider), model);
|
|
97
|
+
if (provider) {
|
|
98
|
+
if (!checkedProviders.has(provider)) {
|
|
99
|
+
checkedProviders.add(provider);
|
|
100
|
+
const auth = providersAuthenticated(provider, providers);
|
|
101
|
+
add(checks, `provider ${provider} auth`, auth.ok, auth.detail, auth.ok ? "ok" : "missing");
|
|
102
|
+
}
|
|
103
|
+
if (options.providerSmoke && !smokedModels.has(model)) {
|
|
104
|
+
smokedModels.add(model);
|
|
105
|
+
const smoke = smokeProvider(model, options.cwd || process.cwd());
|
|
106
|
+
add(checks, `provider ${provider} smoke`, smoke.ok, smoke.detail, smoke.ok ? "ok" : "missing");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!options.providerSmoke) {
|
|
112
|
+
add(checks, "provider smoke", false, "run with --provider-smoke before long scripted runs", "warn");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const payload = projectFreeformData(telemetry ? { checks, env, telemetry } : { checks, env });
|
|
116
|
+
if (options.json) {
|
|
117
|
+
console.log(serializeTerminalJson(payload, { space: 2 }));
|
|
118
|
+
} else {
|
|
119
|
+
if (options.profiles) printProfileMap(env.resolved_models, env.resolved_variants);
|
|
120
|
+
for (const check of payload.checks) console.log(renderDoctorRow(check));
|
|
121
|
+
}
|
|
122
|
+
return checks.every((check) => check.level !== "missing");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function readOpencodeConfig(configPath = join(homedir(), ".config", "opencode", "opencode.jsonc")) {
|
|
126
|
+
return readJsoncConfig(configPath, { label: "opencode.jsonc" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function collectTelemetryReadiness({ cfg = {}, pluginOptions = {}, env = process.env, instrumentationLoadability } = {}) {
|
|
130
|
+
const loadability = instrumentationLoadability || await checkOpenTelemetryApiLoadability();
|
|
131
|
+
const opencode = evaluateOpenTelemetryConfigReadiness(cfg);
|
|
132
|
+
return scrubSensitiveData({
|
|
133
|
+
opencode,
|
|
134
|
+
otlpEnv: evaluateOtlpEnvReadiness(env),
|
|
135
|
+
companionPlugin: evaluateCompanionTelemetryPluginReadiness(cfg),
|
|
136
|
+
instrumentation: evaluatePackageInstrumentationLoadability(loadability),
|
|
137
|
+
featureFactory: evaluateFeatureFactoryTelemetryReadiness({ cfg, pluginOptions, env, nativeOpenTelemetry: opencode.enabled }),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function evaluateOpenTelemetryConfigReadiness(cfg = {}) {
|
|
142
|
+
const experimental = plainObject(cfg.experimental) ? cfg.experimental : {};
|
|
143
|
+
const value = experimental.openTelemetry;
|
|
144
|
+
const enabled = value === true;
|
|
145
|
+
return {
|
|
146
|
+
ok: enabled,
|
|
147
|
+
level: enabled ? "ok" : "warn",
|
|
148
|
+
enabled,
|
|
149
|
+
configured: value !== undefined,
|
|
150
|
+
detail: enabled
|
|
151
|
+
? "experimental.openTelemetry=true; native opencode/AI SDK spans may be emitted when an SDK/exporter is initialized"
|
|
152
|
+
: `experimental.openTelemetry=${value === undefined ? "unset" : safeValue(value)}; enable it for native opencode AI SDK spans`,
|
|
153
|
+
nativeAiSdkSpansExpected: enabled,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function evaluateOtlpEnvReadiness(env = process.env) {
|
|
158
|
+
const safeOtlp = sanitizeOtlpEnv(env || {});
|
|
159
|
+
const endpoint = firstPresent(OTEL_ENDPOINT_KEYS, env);
|
|
160
|
+
const headers = OTEL_HEADER_KEYS
|
|
161
|
+
.filter((key) => stringValue(env?.[key]))
|
|
162
|
+
.map((key) => ({ key, headers: safeOtlp[key] || [] }));
|
|
163
|
+
const resourceAttributes = parseOtelKeyValueList(env?.[OTEL_RESOURCE_ATTRIBUTES]);
|
|
164
|
+
const serviceNameFromResource = resourceAttributes.find((item) => item.name === "service.name");
|
|
165
|
+
const serviceName = stringValue(env?.[OTEL_SERVICE_NAME])
|
|
166
|
+
? { source: OTEL_SERVICE_NAME, value: safeValue(env[OTEL_SERVICE_NAME]) }
|
|
167
|
+
: serviceNameFromResource
|
|
168
|
+
? { source: OTEL_RESOURCE_ATTRIBUTES, value: safeValue(serviceNameFromResource.value) }
|
|
169
|
+
: null;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
ok: Boolean(endpoint),
|
|
173
|
+
level: endpoint ? "ok" : "missing",
|
|
174
|
+
endpoint: endpoint
|
|
175
|
+
? { ok: true, key: endpoint.key, value: sanitizeEndpointSummary(endpoint.value) }
|
|
176
|
+
: { ok: false, missing: OTEL_ENDPOINT_KEYS },
|
|
177
|
+
headers: {
|
|
178
|
+
ok: headers.some((item) => item.headers.length > 0),
|
|
179
|
+
vars: headers,
|
|
180
|
+
missing: headers.length === 0 ? OTEL_HEADER_KEYS : [],
|
|
181
|
+
detail: headers.length > 0 ? headerDetail(headers) : "no OTLP header variables configured",
|
|
182
|
+
},
|
|
183
|
+
resource: {
|
|
184
|
+
ok: Boolean(serviceName),
|
|
185
|
+
serviceName,
|
|
186
|
+
attributes: resourceAttributes.map((item) => ({ name: item.name, present: true })),
|
|
187
|
+
detail: serviceName ? `service.name from ${serviceName.source}` : "set OTEL_SERVICE_NAME or service.name in OTEL_RESOURCE_ATTRIBUTES",
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function evaluateCompanionTelemetryPluginReadiness(cfg = {}) {
|
|
193
|
+
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
194
|
+
const matches = plugins
|
|
195
|
+
.map((entry) => companionPluginCandidate(entry))
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
|
|
198
|
+
if (matches.length === 0) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
level: "warn",
|
|
202
|
+
present: false,
|
|
203
|
+
detail: "no companion telemetry plugin configured; use native opencode OTel or install @devtheops/opencode-plugin-otel",
|
|
204
|
+
action: "Configure native experimental.openTelemetry or add a companion telemetry plugin that initializes an OpenTelemetry SDK/exporter.",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const unusable = matches.filter((item) => item.unusable);
|
|
209
|
+
return {
|
|
210
|
+
ok: unusable.length === 0,
|
|
211
|
+
level: unusable.length === 0 ? "ok" : "warn",
|
|
212
|
+
present: true,
|
|
213
|
+
plugins: matches,
|
|
214
|
+
detail: unusable.length === 0
|
|
215
|
+
? `companion telemetry plugin configured: ${matches.map((item) => item.spec).join(", ")}`
|
|
216
|
+
: `companion telemetry plugin needs attention: ${unusable.map((item) => `${item.spec} (${item.reason})`).join(", ")}`,
|
|
217
|
+
action: unusable.length === 0 ? "none" : "Enable or fix the companion telemetry plugin options.",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function evaluatePackageInstrumentationLoadability(loadability = {}) {
|
|
222
|
+
return {
|
|
223
|
+
ok: loadability.ok === true,
|
|
224
|
+
level: loadability.ok === true ? "ok" : "missing",
|
|
225
|
+
package: loadability.package || "@opentelemetry/api",
|
|
226
|
+
exports: Array.isArray(loadability.exports) ? loadability.exports : [],
|
|
227
|
+
detail: loadability.ok === true
|
|
228
|
+
? `${loadability.package || "@opentelemetry/api"} loadable`
|
|
229
|
+
: safeValue(loadability.error || `${loadability.package || "@opentelemetry/api"} is not loadable`),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function evaluateFeatureFactoryTelemetryReadiness({ cfg = {}, pluginOptions = {}, env = process.env, nativeOpenTelemetry } = {}) {
|
|
234
|
+
const telemetryOptions = plainObject(pluginOptions.telemetry) ? pluginOptions.telemetry : {};
|
|
235
|
+
const envEnabled = parseBooleanEnv(env?.FEATURE_FACTORY_OTEL_ENABLED);
|
|
236
|
+
const optionEnabled = telemetryOptions.enabled === true;
|
|
237
|
+
const enabled = optionEnabled || envEnabled === true;
|
|
238
|
+
const risk = evaluateContentCaptureRisk({
|
|
239
|
+
config: cfg,
|
|
240
|
+
telemetry: telemetryOptions,
|
|
241
|
+
nativeOpenTelemetry: nativeOpenTelemetry === true,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ok: risk.ok,
|
|
246
|
+
level: risk.ok ? "ok" : "warn",
|
|
247
|
+
enabled,
|
|
248
|
+
mode: safeValue(telemetryOptions.mode || (nativeOpenTelemetry ? "native-opencode" : "noop")),
|
|
249
|
+
source: optionEnabled ? "plugin.telemetry.enabled" : envEnabled === true ? "FEATURE_FACTORY_OTEL_ENABLED" : "default-off",
|
|
250
|
+
redactionActive: risk.redactionActive === true,
|
|
251
|
+
capture: risk.capture,
|
|
252
|
+
risks: risk.risks,
|
|
253
|
+
detail: risk.ok
|
|
254
|
+
? `feature-factory telemetry ${enabled ? "enabled" : "off by default"}; content capture disabled and redaction active`
|
|
255
|
+
: risk.risks.map((item) => item.message).join(" "),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function add(checks, label, passed, detail, failureLevel = "missing") {
|
|
260
|
+
checks.push({ label, level: passed ? "ok" : failureLevel, detail: String(detail ?? "") });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function addTelemetryChecks(checks, telemetry) {
|
|
264
|
+
add(checks, "telemetry opencode experimental.openTelemetry", telemetry.opencode.ok, telemetry.opencode.detail, telemetry.opencode.level);
|
|
265
|
+
add(checks, "telemetry native AI SDK spans", telemetry.opencode.nativeAiSdkSpansExpected, telemetry.opencode.nativeAiSdkSpansExpected ? "expected when SDK/exporter is initialized" : "not expected until experimental.openTelemetry is true", "warn");
|
|
266
|
+
add(checks, "telemetry OTLP endpoint", telemetry.otlpEnv.endpoint.ok, formatTelemetryEndpointDetail(telemetry.otlpEnv.endpoint), telemetry.otlpEnv.level);
|
|
267
|
+
add(checks, "telemetry OTLP headers", telemetry.otlpEnv.headers.ok, telemetry.otlpEnv.headers.detail, "warn");
|
|
268
|
+
add(checks, "telemetry resource service", telemetry.otlpEnv.resource.ok, telemetry.otlpEnv.resource.detail, "warn");
|
|
269
|
+
add(checks, "telemetry companion plugin", telemetry.companionPlugin.ok, telemetry.companionPlugin.detail, telemetry.companionPlugin.level);
|
|
270
|
+
add(checks, "telemetry package instrumentation", telemetry.instrumentation.ok, telemetry.instrumentation.detail, telemetry.instrumentation.level);
|
|
271
|
+
add(checks, "telemetry feature-factory options", telemetry.featureFactory.enabled, telemetry.featureFactory.detail, "warn");
|
|
272
|
+
add(checks, "telemetry content capture risk", telemetry.featureFactory.ok, telemetry.featureFactory.detail, telemetry.featureFactory.level);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function formatTelemetryEndpointDetail(endpoint) {
|
|
276
|
+
if (endpoint.ok) return `${endpoint.key}=${endpoint.value}`;
|
|
277
|
+
return `missing ${endpoint.missing.join(" or ")}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function headerDetail(headers) {
|
|
281
|
+
return headers
|
|
282
|
+
.map((item) => {
|
|
283
|
+
const names = item.headers.map((header) => header.name).join(", ") || "no parseable headers";
|
|
284
|
+
return `${item.key}: ${names}`;
|
|
285
|
+
})
|
|
286
|
+
.join("; ");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function firstPresent(keys, env) {
|
|
290
|
+
for (const key of keys) {
|
|
291
|
+
if (stringValue(env?.[key])) return { key, value: env[key] };
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseOtelKeyValueList(value) {
|
|
297
|
+
if (!stringValue(value)) return [];
|
|
298
|
+
return String(value)
|
|
299
|
+
.split(",")
|
|
300
|
+
.map((entry) => {
|
|
301
|
+
const index = entry.indexOf("=");
|
|
302
|
+
const rawName = index === -1 ? entry : entry.slice(0, index);
|
|
303
|
+
const rawValue = index === -1 ? "" : entry.slice(index + 1);
|
|
304
|
+
const name = sanitizePublicName(rawName);
|
|
305
|
+
if (!name) return null;
|
|
306
|
+
return { name, value: safeValue(rawValue), present: true };
|
|
307
|
+
})
|
|
308
|
+
.filter(Boolean);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function companionPluginCandidate(entry) {
|
|
312
|
+
const spec = pluginEntrySpec(entry);
|
|
313
|
+
if (!stringValue(spec)) return null;
|
|
314
|
+
const identity = pluginSpecIdentity(spec);
|
|
315
|
+
if (!identity || FEATURE_FACTORY_PLUGIN_SPECS.has(identity)) return null;
|
|
316
|
+
if (!COMPANION_TELEMETRY_PLUGIN_SPECS.has(identity)) return null;
|
|
317
|
+
|
|
318
|
+
const options = Array.isArray(entry) && plainObject(entry[1]) ? entry[1] : {};
|
|
319
|
+
const disabled = options.enabled === false || options.telemetry?.enabled === false;
|
|
320
|
+
return {
|
|
321
|
+
spec: safeValue(spec),
|
|
322
|
+
present: true,
|
|
323
|
+
unusable: disabled,
|
|
324
|
+
reason: disabled ? "configured disabled" : undefined,
|
|
325
|
+
action: disabled ? "set enabled=true or remove the disabled companion plugin entry" : "none",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function pluginEntrySpec(entry) {
|
|
330
|
+
return Array.isArray(entry) ? entry[0] : entry;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function pluginSpecIdentity(spec) {
|
|
334
|
+
const raw = String(spec || "").trim();
|
|
335
|
+
if (!raw) return null;
|
|
336
|
+
const packageSpec = normalizePackageSpec(raw);
|
|
337
|
+
if (packageSpec) return packageSpec;
|
|
338
|
+
|
|
339
|
+
let pathSpec = raw;
|
|
340
|
+
try {
|
|
341
|
+
const parsed = new URL(raw);
|
|
342
|
+
pathSpec = parsed.pathname || raw;
|
|
343
|
+
} catch {
|
|
344
|
+
// Non-URL local paths are handled below.
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const segments = pathSpec
|
|
348
|
+
.split(/[\\/]+/u)
|
|
349
|
+
.map((segment) => safeDecodeURIComponent(segment).trim())
|
|
350
|
+
.filter(Boolean);
|
|
351
|
+
if (segments.length === 0) return null;
|
|
352
|
+
|
|
353
|
+
if (segments.at(-1) === "plugin.js" && segments.at(-2) === "src") {
|
|
354
|
+
return normalizePackageSpec(segments.at(-3));
|
|
355
|
+
}
|
|
356
|
+
return normalizePackageSpec(segments.at(-1));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizePackageSpec(value) {
|
|
360
|
+
const normalized = String(value || "")
|
|
361
|
+
.trim()
|
|
362
|
+
.replace(/^npm:/iu, "")
|
|
363
|
+
.toLowerCase()
|
|
364
|
+
.replace(/\/+$/u, "");
|
|
365
|
+
if (!normalized) return null;
|
|
366
|
+
|
|
367
|
+
const scoped = /^(@[^/@]+\/[^/@]+?)(?:@[^/@]+)?$/u.exec(normalized);
|
|
368
|
+
if (scoped) return scoped[1];
|
|
369
|
+
|
|
370
|
+
const unscoped = /^([^/@:]+)(?:@[^/@]+)?$/u.exec(normalized);
|
|
371
|
+
return unscoped ? unscoped[1] : null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function sanitizePublicName(value) {
|
|
375
|
+
const name = String(value || "").trim();
|
|
376
|
+
if (!name) return null;
|
|
377
|
+
if (isSensitiveKey(name, { mode: "baseline" })) return REDACTED_ENV_VALUE;
|
|
378
|
+
return scrubSensitiveString(name, { mode: "baseline" });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function safeValue(value) {
|
|
382
|
+
return scrubSensitiveString(String(value ?? ""), { mode: "baseline" });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function sanitizeEndpointSummary(value) {
|
|
386
|
+
if (!stringValue(value)) return "unset";
|
|
387
|
+
const raw = String(value).trim();
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const parsed = new URL(raw);
|
|
391
|
+
const safePath = parsed.pathname
|
|
392
|
+
.split("/")
|
|
393
|
+
.map((segment) => sanitizeEndpointPathSegment(segment))
|
|
394
|
+
.join("/") || "/";
|
|
395
|
+
const safeHost = sanitizeEndpointHost(parsed);
|
|
396
|
+
const safeQuery = endpointSearchHasValues(parsed.searchParams) ? `?${REDACTED_ENV_VALUE}` : "";
|
|
397
|
+
return `${parsed.protocol}//${safeHost}${safePath}${safeQuery}`;
|
|
398
|
+
} catch {
|
|
399
|
+
return endpointValueLooksSensitive(raw) ? REDACTED_ENV_VALUE : safeValue(raw);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function sanitizeEndpointHost(parsed) {
|
|
404
|
+
const hostname = parsed.hostname;
|
|
405
|
+
const port = parsed.port ? `:${parsed.port}` : "";
|
|
406
|
+
if (!hostname) return scrubSensitiveString(parsed.host || "", { mode: "endpoint" });
|
|
407
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return `${hostname}${port}`;
|
|
408
|
+
const safeHostname = hostname
|
|
409
|
+
.split(".")
|
|
410
|
+
.map((label) => sanitizeEndpointHostLabel(label))
|
|
411
|
+
.join(".");
|
|
412
|
+
return `${safeHostname}${port}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sanitizeEndpointHostLabel(label) {
|
|
416
|
+
if (!label) return label;
|
|
417
|
+
const decoded = safeDecodeURIComponent(label);
|
|
418
|
+
if (isSensitiveValue(decoded, { mode: "endpoint" })) return REDACTED_ENV_VALUE;
|
|
419
|
+
return scrubSensitiveString(label, { mode: "endpoint" });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function sanitizeEndpointPathSegment(segment) {
|
|
423
|
+
if (!segment) return segment;
|
|
424
|
+
const decoded = safeDecodeURIComponent(segment);
|
|
425
|
+
if (isSensitiveValue(decoded, { mode: "endpoint" })) return REDACTED_ENV_VALUE;
|
|
426
|
+
return scrubSensitiveString(segment, { mode: "endpoint" });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function endpointSearchHasValues(searchParams) {
|
|
430
|
+
for (const [key, value] of searchParams.entries()) {
|
|
431
|
+
if (key || value) return true;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function endpointValueLooksSensitive(value) {
|
|
437
|
+
return isSensitiveValue(String(value || "").trim(), { mode: "endpoint" });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function safeDecodeURIComponent(value) {
|
|
441
|
+
try {
|
|
442
|
+
return decodeURIComponent(value);
|
|
443
|
+
} catch {
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function parseBooleanEnv(value) {
|
|
449
|
+
if (!stringValue(value)) return undefined;
|
|
450
|
+
const normalized = String(value).trim().toLowerCase();
|
|
451
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
452
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function plainObject(value) {
|
|
457
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function stringValue(value) {
|
|
461
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function missingSubagents(agents = {}) {
|
|
465
|
+
return SUBAGENTS.filter((name) => !agents[name]);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function staleProfileKeys(options = {}) {
|
|
469
|
+
return ["model", "models", "variant", "variants"].filter((key) => Object.prototype.hasOwnProperty.call(options, key));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function permissionFailures(agents = {}) {
|
|
473
|
+
const failures = [];
|
|
474
|
+
for (const name of ["feature-factory", ...SUBAGENTS]) {
|
|
475
|
+
const permission = agents[name]?.permission || {};
|
|
476
|
+
for (const key of NON_INTERACTIVE_ALLOW) {
|
|
477
|
+
if (permission[key] !== "allow") failures.push(`${name}.${key}=${permission[key] || "unset"}`);
|
|
478
|
+
}
|
|
479
|
+
for (const key of FACTORY_DENY) {
|
|
480
|
+
if (permission[key] !== "deny") failures.push(`${name}.${key}=${permission[key] || "unset"}`);
|
|
481
|
+
}
|
|
482
|
+
const expectedEdit = EDIT_AGENTS.has(name) ? "allow" : "deny";
|
|
483
|
+
if (permission.edit !== expectedEdit) failures.push(`${name}.edit=${permission.edit || "unset"}`);
|
|
484
|
+
}
|
|
485
|
+
return failures;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function hasTuiExport(packageJsonPath = join(root, "package.json")) {
|
|
489
|
+
const pkg = readPackageJson(packageJsonPath);
|
|
490
|
+
return typeof pkg.exports?.["./tui"] === "string";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function readPackageJson(packageJsonPath) {
|
|
494
|
+
try {
|
|
495
|
+
return readStrictJsonConfig(packageJsonPath, { label: "package.json" });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error instanceof SyntaxError) {
|
|
498
|
+
const detail = String(error.message || "invalid JSON syntax").replace(/^package\.json:\s*/u, "");
|
|
499
|
+
throw new SyntaxError(`Invalid JSON in package.json: ${detail}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function findPluginEntry(cfg, pluginSpec, local) {
|
|
507
|
+
for (const entry of cfg.plugin || []) {
|
|
508
|
+
const spec = Array.isArray(entry) ? entry[0] : entry;
|
|
509
|
+
if (spec === pluginSpec) return entry;
|
|
510
|
+
if (!local && spec === "opencode-feature-factory") return entry;
|
|
511
|
+
if (local && typeof spec === "string" && spec.endsWith("/opencode-feature-factory")) return entry;
|
|
512
|
+
if (local && typeof spec === "string" && spec.endsWith("/opencode-feature-factory/src/plugin.js")) return entry;
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function providerAuthState() {
|
|
518
|
+
const proc = runOpencode(["providers", "list"]);
|
|
519
|
+
return proc.ok ? `${proc.stdout}\n${proc.stderr}`.toLowerCase() : "";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function modelProvider(model) {
|
|
523
|
+
const [provider, name] = String(model).split("/");
|
|
524
|
+
return provider && name ? provider : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function providersAuthenticated(provider, providersOutput) {
|
|
528
|
+
const env = providerEnv(provider).filter((name) => process.env[name]);
|
|
529
|
+
if (env.length) return { ok: true, detail: `env ${env.join(",")}` };
|
|
530
|
+
if (providersOutput.includes(provider.toLowerCase())) return { ok: true, detail: "opencode providers list" };
|
|
531
|
+
return { ok: false, detail: `no auth found for ${provider}` };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function providerEnv(provider) {
|
|
535
|
+
const key = provider.toLowerCase();
|
|
536
|
+
const known = {
|
|
537
|
+
openai: ["OPENAI_API_KEY"],
|
|
538
|
+
anthropic: ["ANTHROPIC_API_KEY"],
|
|
539
|
+
google: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
|
540
|
+
gemini: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
|
541
|
+
github: ["GITHUB_TOKEN"],
|
|
542
|
+
groq: ["GROQ_API_KEY"],
|
|
543
|
+
openrouter: ["OPENROUTER_API_KEY"],
|
|
544
|
+
minimax: ["MINIMAX_API_KEY"],
|
|
545
|
+
};
|
|
546
|
+
return known[key] || [`${key.toUpperCase()}_API_KEY`];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function smokeProvider(model, cwd) {
|
|
550
|
+
const proc = runOpencode(["run", "--dir", cwd, "--model", model, "Reply OK only."], { cwd, maxBuffer: 1024 * 1024 });
|
|
551
|
+
const output = projectFreeformData(`${proc.stdout || ""}\n${proc.stderr || ""}`.trim());
|
|
552
|
+
return { ok: proc.ok, detail: proc.ok ? "smoke passed" : output.slice(0, 300) };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function runOpencode(args, options = {}) {
|
|
556
|
+
try {
|
|
557
|
+
return {
|
|
558
|
+
ok: true,
|
|
559
|
+
stdout: execFileSync("opencode", args, { cwd: options.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: options.timeout || 30000, maxBuffer: options.maxBuffer || 1024 * 1024 }),
|
|
560
|
+
stderr: "",
|
|
561
|
+
status: 0,
|
|
562
|
+
};
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
stdout: normalizeCommandOutput(error.stdout),
|
|
567
|
+
stderr: normalizeCommandOutput(error.stderr || error.message),
|
|
568
|
+
status: error.status ?? 1,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function normalizeCommandOutput(value) {
|
|
574
|
+
if (typeof value === "string") return value;
|
|
575
|
+
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
|
576
|
+
return String(value || "");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function printProfileMap(models, variants) {
|
|
580
|
+
for (const [agent, model] of Object.entries(models)) {
|
|
581
|
+
console.log([
|
|
582
|
+
"profile: ", renderDoctorValue(agent),
|
|
583
|
+
" -> model=", renderDoctorValue(model || "<opencode default>"),
|
|
584
|
+
" variant=", renderDoctorValue(variants[agent] || "<opencode default>"),
|
|
585
|
+
].join(""));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function renderDoctorRow(check) {
|
|
590
|
+
return [
|
|
591
|
+
renderDoctorValue(check.level), ": ", renderDoctorValue(check.label),
|
|
592
|
+
" (", renderDoctorValue(check.detail), ")",
|
|
593
|
+
].join("");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function renderDoctorValue(value) {
|
|
597
|
+
return renderTerminalSegments([freeformSegment(value)]);
|
|
598
|
+
}
|