@litfamily/litopencode 1.0.28 → 1.0.30
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/CHANGELOG.md +28 -0
- package/README-Ko-KR.md +7 -7
- package/README.md +7 -7
- package/dist/activation-primary-prompts.js +15 -6
- package/dist/activation-prompt-utils.d.ts +2 -2
- package/dist/activation-prompt-utils.js +9 -3
- package/dist/activation-reentry.d.ts +29 -0
- package/dist/activation-reentry.js +71 -0
- package/dist/activation-routing.d.ts +1 -1
- package/dist/activation-routing.js +40 -7
- package/dist/activation-workflow-prompts.d.ts +2 -0
- package/dist/activation-workflow-prompts.js +32 -7
- package/dist/activation.d.ts +6 -1
- package/dist/activation.js +34 -7
- package/dist/cli/args.js +14 -0
- package/dist/cli/auto-update.js +2 -4
- package/dist/cli/command-plan.d.ts +40 -0
- package/dist/cli/command-plan.js +62 -0
- package/dist/cli/doctor.d.ts +4 -1
- package/dist/cli/doctor.js +65 -4
- package/dist/cli/host-detect.d.ts +58 -0
- package/dist/cli/host-detect.js +199 -0
- package/dist/cli/install-report.js +3 -1
- package/dist/cli/install.d.ts +9 -1
- package/dist/cli/install.js +134 -31
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +5 -3
- package/dist/cli/office-dependencies.d.ts +2 -0
- package/dist/cli/office-dependencies.js +7 -3
- package/dist/cli/scientific-visualization-dependencies.js +1 -1
- package/dist/cli/tui-config.d.ts +27 -2
- package/dist/cli/tui-config.js +77 -2
- package/dist/cli/types.d.ts +17 -0
- package/dist/cli.js +9 -6
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +17 -3
- package/dist/deliverable-hedge-guard.js +21 -9
- package/dist/features.d.ts +27 -1
- package/dist/features.js +40 -8
- package/dist/host/server.d.ts +7 -0
- package/dist/host/server.js +12 -0
- package/dist/host/tui.d.ts +7 -0
- package/dist/host/tui.js +12 -0
- package/dist/server.js +11 -1
- package/dist/skill-renames.d.ts +1 -1
- package/dist/skills.d.ts +9 -2
- package/dist/skills.js +22 -8
- package/dist/tui/mission-source-v2.d.ts +51 -0
- package/dist/tui/mission-source-v2.js +52 -0
- package/dist/v2/agents.d.ts +105 -0
- package/dist/v2/agents.js +185 -0
- package/dist/v2/commands.d.ts +44 -0
- package/dist/v2/commands.js +65 -0
- package/dist/v2/context.d.ts +67 -0
- package/dist/v2/context.js +17 -0
- package/dist/v2/events.d.ts +55 -0
- package/dist/v2/events.js +193 -0
- package/dist/v2/hooks.d.ts +39 -0
- package/dist/v2/hooks.js +61 -0
- package/dist/v2/server.d.ts +2 -0
- package/dist/v2/server.js +68 -0
- package/dist/v2/session-hooks.d.ts +64 -0
- package/dist/v2/session-hooks.js +230 -0
- package/dist/v2/tools.d.ts +26 -0
- package/dist/v2/tools.js +49 -0
- package/dist/v2/tui.d.ts +63 -0
- package/dist/v2/tui.js +259 -0
- package/docs/assets/cover-motion-still.webp +0 -0
- package/docs/assets/cover-motion.webp +0 -0
- package/docs/assets/cover.webp +0 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/readme/litfamily-machines.png +0 -0
- package/docs/migration.md +4 -3
- package/docs/reference-Ko-KR.md +109 -7
- package/docs/reference.md +122 -9
- package/package.json +10 -5
- package/rules/bundled-rules/lit-humanizer.md +2 -0
- package/skills/deep-interview/SKILL.md +1 -1
- package/skills/lit-brainstorm/SKILL.md +299 -0
- package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
- package/skills/lit-brainstorm/references/lenses.md +106 -0
- package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
- package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
- package/skills/lit-docx/SKILL.md +1 -1
- package/skills/lit-docx/run.py +42 -4
- package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
- package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
- package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
- package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
- package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
- package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
- package/skills/lit-humanizer/SKILL.md +4 -0
- package/skills/lit-humanizer/scripts/core.mjs +81 -1
- package/skills/lit-humanizer/scripts/detect.mjs +4 -3
- package/skills/lit-humanizer/scripts/guard-scan.mjs +12 -6
- package/skills/lit-pptx/SKILL.md +1 -1
- package/skills/lit-pptx/command.mjs +94 -0
- package/skills/lit-pptx/run.mjs +16 -6
- package/skills/lit-pptx/scripts/compile-deck.js +2 -2
- package/skills/lit-typographic-motion/SKILL.md +5 -2
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
- package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
- package/skills/lit-typographic-motion/engine/command.mjs +94 -0
- package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
- package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
- package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
- package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
- package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
- package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
- package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
- package/skills/lit-typographic-motion/engine/look.mjs +113 -10
- package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
- package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
- package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
- package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
- package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
- package/skills/lit-typographic-motion/fonts/pins.json +3 -1
- package/skills/lit-typographic-motion/gate.mjs +34 -1
- package/skills/lit-typographic-motion/probe.mjs +8 -3
- package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
- package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
- package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
- package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
- package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
- package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
- package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
- package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
- package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
- package/skills/lit-typographic-motion/references/cover.md +423 -0
- package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
- package/skills/lit-typographic-motion/references/runtime.md +17 -2
- package/skills/lit-typographic-motion/references/stage.md +3 -0
- package/skills/lit-typographic-motion/references/treatment.md +4 -0
- package/skills/lit-typographic-motion/render.mjs +30 -6
- package/skills/lit-typographic-motion/runtime.mjs +12 -13
- package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
- package/skills/managed-skill-manifest.json +175 -31
- package/skills/review-work/SKILL.md +27 -18
- package/skills/skill-rename-aliases.json +2 -1
- package/tools/check-pack-payload.mjs +4 -1
- package/tools/payload-substance-allowlist.json +1 -1
- package/tools/payload-substance-parity.json +6 -4
- package/tools/qa-cover-recipes.mjs +167 -0
- package/tools/version-manifests.json +6 -0
package/dist/activation.js
CHANGED
|
@@ -7,6 +7,7 @@ import { autoHandoffSettingText } from "./auto-handoff.js";
|
|
|
7
7
|
import { isReadOnlyWorkflowFamilyPlan } from "./workflow-families.js";
|
|
8
8
|
import { queryKnowledge } from "./knowledge.js";
|
|
9
9
|
import { activationDiscipline } from "./activation-probe.js";
|
|
10
|
+
import { decideReentry } from "./activation-reentry.js";
|
|
10
11
|
import { micro, supportsMarkGlyphs } from "./lit-mark.js";
|
|
11
12
|
// Prompt modules preserve minimum-first, single-task or few-task planning, avoidable custom code review,
|
|
12
13
|
// and external-source safety; this module only coordinates their unchanged activation surfaces.
|
|
@@ -14,7 +15,7 @@ export { litHandoffPromptInjection, scientificVisualizationPromptInjection } fro
|
|
|
14
15
|
export { litActivationBanner } from "./activation-prompt-utils.js";
|
|
15
16
|
export { litLoopPromptInjection, litPlanPromptInjection } from "./activation-primary-prompts.js";
|
|
16
17
|
export { containsStandaloneLitTrigger, detectChatActivationMode } from "./activation-routing.js";
|
|
17
|
-
export { comprehendPromptInjection, debuggingPromptInjection, deepInterviewPromptInjection, gitMasterPromptInjection, litCruciblePromptInjection, initDeepPromptInjection, litGoalPromptInjection, litRecapPromptInjection, litResearchPromptInjection, lspPromptInjection, lspSetupPromptInjection, litCodePromptInjection, refactorPromptInjection, removeAiSlopsPromptInjection, browserDrivePromptInjection, structuralSearchPromptInjection, rulesPromptInjection, reviewWorkPromptInjection, startWorkChatPromptInjection, startWorkPromptInjection, litBurnoffFilePromptInjection, litFetchPromptInjection, litHumanizerPromptInjection } from "./activation-workflow-prompts.js";
|
|
18
|
+
export { comprehendPromptInjection, debuggingPromptInjection, deepInterviewPromptInjection, gitMasterPromptInjection, litBrainstormPromptInjection, litCruciblePromptInjection, initDeepPromptInjection, litGoalPromptInjection, litRecapPromptInjection, litResearchPromptInjection, lspPromptInjection, lspSetupPromptInjection, litCodePromptInjection, refactorPromptInjection, removeAiSlopsPromptInjection, browserDrivePromptInjection, structuralSearchPromptInjection, rulesPromptInjection, reviewModeForcedNote, reviewWorkPromptInjection, startWorkChatPromptInjection, startWorkPromptInjection, litBurnoffFilePromptInjection, litFetchPromptInjection, litHumanizerPromptInjection } from "./activation-workflow-prompts.js";
|
|
18
19
|
export function showActivationToast(client, skillId, env = process.env) {
|
|
19
20
|
const label = `🔥 LIT IGNITED · ${activationDiscipline(skillId)} 🔥`;
|
|
20
21
|
const message = supportsMarkGlyphs(env)
|
|
@@ -134,7 +135,8 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
|
|
|
134
135
|
if (mode === undefined || childSession)
|
|
135
136
|
return;
|
|
136
137
|
const trigger = mode === "lit-handoff" ? "handoff" : mode === "lit-scientific-visualization" ? mode : "lit";
|
|
137
|
-
|
|
138
|
+
const triggerText = nonEmptyTextParts.find((part) => detectChatActivationMode(part.text, agent) === mode)?.text;
|
|
139
|
+
let activationText = promptForChatActivationMode(mode, triggerText);
|
|
138
140
|
if (mode === "start-work" && agent !== "lit-plan" && nonEmptyTextParts.length === 1) {
|
|
139
141
|
const directive = parseStartWorkLifecycleDirective(nonEmptyTextParts[0].text, { chat: true });
|
|
140
142
|
if (directive?.action === "resume") {
|
|
@@ -147,25 +149,50 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
|
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
151
|
}
|
|
152
|
+
const reentry = options.activationMemory === undefined
|
|
153
|
+
? { kind: "full" }
|
|
154
|
+
: await decideReentry({
|
|
155
|
+
memory: options.activationMemory,
|
|
156
|
+
projectRoot,
|
|
157
|
+
sessionID: input.sessionID,
|
|
158
|
+
mode,
|
|
159
|
+
text: triggerText ?? ""
|
|
160
|
+
});
|
|
161
|
+
const markMode = reentry.kind === "continue" ? reentry.mode : mode;
|
|
162
|
+
if (reentry.kind === "continue")
|
|
163
|
+
activationText = reentry.text;
|
|
164
|
+
else if (reentry.kind === "resume")
|
|
165
|
+
activationText = `${reentry.line}\n${activationText}`;
|
|
166
|
+
else if (reentry.kind === "confirm")
|
|
167
|
+
activationText = `${activationText}\n${reentry.line}`;
|
|
150
168
|
output.parts.push({
|
|
151
|
-
id: `prt_litopencode_${
|
|
169
|
+
id: `prt_litopencode_${markMode.replace("-", "_")}_${idSuffix(input.messageID ?? output.message.id)}`,
|
|
152
170
|
sessionID: input.sessionID,
|
|
153
171
|
messageID: input.messageID ?? output.message.id,
|
|
154
172
|
type: "text",
|
|
155
173
|
text: activationText,
|
|
156
174
|
metadata: {
|
|
157
175
|
litopencode: {
|
|
158
|
-
mode,
|
|
176
|
+
mode: markMode,
|
|
159
177
|
trigger,
|
|
160
|
-
source: "chat.message"
|
|
178
|
+
source: "chat.message",
|
|
179
|
+
...(reentry.kind === "full" ? {} : { reentry: reentry.kind })
|
|
161
180
|
}
|
|
162
181
|
}
|
|
163
182
|
});
|
|
164
|
-
|
|
183
|
+
const appendedLit = nonEmptyTextParts.some((part) => endsWithStandaloneLitInvocation(part.text));
|
|
184
|
+
if (reentry.kind === "continue") {
|
|
185
|
+
await options.onSkillContinue?.(input.sessionID, markMode, appendedLit);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await options.onSkillActivation?.(input.sessionID, mode, appendedLit);
|
|
189
|
+
if (reentry.kind !== "confirm" && mode !== "lit-handoff" && mode !== "lit-scientific-visualization") {
|
|
190
|
+
options.activationMemory?.remember(input.sessionID, mode);
|
|
191
|
+
}
|
|
165
192
|
if (mode === "lit-scientific-visualization") {
|
|
166
193
|
options.onScientificVisualizationActivation?.(input.sessionID);
|
|
167
194
|
}
|
|
168
|
-
if (mode === "lit-task" || mode === "lit-recap" || isReadOnlyWorkflowFamilyPlan(mode))
|
|
195
|
+
if (mode === "lit-task" || mode === "lit-recap" || mode === "lit-brainstorm" || isReadOnlyWorkflowFamilyPlan(mode))
|
|
169
196
|
return;
|
|
170
197
|
try {
|
|
171
198
|
await operations.init();
|
package/dist/cli/args.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { hostChoices, isHostChoice } from "./host-detect.js";
|
|
3
4
|
import { installModelEfforts, isInstallModelEffort, resolveModelRoute } from "./model-catalog.js";
|
|
4
5
|
export const loopCommands = [
|
|
5
6
|
"create-goals",
|
|
@@ -54,6 +55,7 @@ export function parseArgs(argv) {
|
|
|
54
55
|
let summary;
|
|
55
56
|
let directive;
|
|
56
57
|
let resolve;
|
|
58
|
+
let host = "auto";
|
|
57
59
|
if (command === "--help" || command === "-h") {
|
|
58
60
|
command = "help";
|
|
59
61
|
}
|
|
@@ -244,6 +246,17 @@ export function parseArgs(argv) {
|
|
|
244
246
|
index += 1;
|
|
245
247
|
continue;
|
|
246
248
|
}
|
|
249
|
+
if (arg === "--host") {
|
|
250
|
+
const value = argv[index + 1];
|
|
251
|
+
if (!value)
|
|
252
|
+
throw new Error("--host requires a value");
|
|
253
|
+
const cleaned = cleanOption(value, "--host") ?? "";
|
|
254
|
+
if (!isHostChoice(cleaned))
|
|
255
|
+
throw new Error("--host must be one of " + hostChoices.join(", "));
|
|
256
|
+
host = cleaned;
|
|
257
|
+
index += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
247
260
|
if (arg === "--yes") {
|
|
248
261
|
yes = true;
|
|
249
262
|
continue;
|
|
@@ -302,6 +315,7 @@ export function parseArgs(argv) {
|
|
|
302
315
|
modelPrompt: yes ? "never" : modelPrompt,
|
|
303
316
|
outputStylePrompt: yes ? "never" : "auto",
|
|
304
317
|
...(tuiAction === undefined ? {} : { tuiAction }),
|
|
318
|
+
host,
|
|
305
319
|
loop: {
|
|
306
320
|
projectRoot,
|
|
307
321
|
json: loopJson,
|
package/dist/cli/auto-update.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
5
|
import { defaultOpenCodeConfigRoot } from "../state.js";
|
|
8
6
|
import { compareStableVersions, parseStableVersion, requestRegistryLatestVersion } from "./update-notifier.js";
|
|
7
|
+
import { execCommand } from "./command-plan.js";
|
|
9
8
|
import { readPackageMetadata } from "./json.js";
|
|
10
9
|
export const autoUpdatePackageName = "@litfamily/litopencode";
|
|
11
10
|
export const autoUpdateTimeoutMs = 30_000;
|
|
@@ -23,7 +22,6 @@ const journalFileName = "auto-update-journal.jsonl";
|
|
|
23
22
|
const installLockFileName = "auto-update-install.lock";
|
|
24
23
|
const checkFileName = "auto-update-check.json";
|
|
25
24
|
const maxCommandOutputBytes = 4 * 1024 * 1024;
|
|
26
|
-
const execFileAsync = promisify(execFile);
|
|
27
25
|
/**
|
|
28
26
|
* The staged CLI needs Node. Inside OpenCode the plugin runs on a Bun-compiled
|
|
29
27
|
* host binary, and process.execPath names that binary rather than a Node
|
|
@@ -127,7 +125,7 @@ function failureKind(step, error) {
|
|
|
127
125
|
};
|
|
128
126
|
}
|
|
129
127
|
async function defaultRunCommand(command, args, options) {
|
|
130
|
-
return await
|
|
128
|
+
return await execCommand(command, args, options);
|
|
131
129
|
}
|
|
132
130
|
async function bounded(operation, timeoutMs) {
|
|
133
131
|
let timer;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to start a command so Windows accepts it. Node refuses to spawn a `.cmd` or `.bat` file
|
|
3
|
+
* without a shell (CVE-2024-27980, `spawn EINVAL`), and a bare `npm` or `opencode` is not found
|
|
4
|
+
* there because only the `.cmd` shim exists. POSIX runs the command as given.
|
|
5
|
+
*/
|
|
6
|
+
export type CommandPlan = {
|
|
7
|
+
readonly command: string;
|
|
8
|
+
readonly args: readonly string[];
|
|
9
|
+
readonly shell: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type CommandPlanContext = {
|
|
12
|
+
readonly platform?: NodeJS.Platform;
|
|
13
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
14
|
+
readonly execPath?: string;
|
|
15
|
+
readonly versions?: Readonly<Record<string, string | undefined>>;
|
|
16
|
+
/** The path of a command on PATH, PATHEXT included. */
|
|
17
|
+
readonly find?: (command: string) => string | undefined;
|
|
18
|
+
readonly exists?: (file: string) => boolean;
|
|
19
|
+
};
|
|
20
|
+
/** One cmd.exe argument in double quotes. Refuses what quotes cannot protect, so a value never becomes shell syntax. */
|
|
21
|
+
export declare function quoteForCmd(value: string): string;
|
|
22
|
+
export declare function commandPlan(command: string, args: readonly string[], context?: CommandPlanContext): CommandPlan;
|
|
23
|
+
/**
|
|
24
|
+
* npm without a `.cmd` shim in the way. Inside OpenCode the plugin runs on a Bun host binary, so
|
|
25
|
+
* `process.execPath` is not Node: npm is found through its shim's own directory instead, and only
|
|
26
|
+
* then through a quoted shell.
|
|
27
|
+
*/
|
|
28
|
+
export declare function npmPlan(args: readonly string[], context?: CommandPlanContext): CommandPlan;
|
|
29
|
+
type ExecOptions = {
|
|
30
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
31
|
+
readonly cwd?: string;
|
|
32
|
+
readonly timeout?: number;
|
|
33
|
+
readonly maxBuffer?: number;
|
|
34
|
+
};
|
|
35
|
+
/** `execFile` through a CommandPlan; `npm` is resolved by `npmPlan`. A failure keeps the child's stderr and exit code. */
|
|
36
|
+
export declare function execCommand(command: string, args: readonly string[], options?: ExecOptions): Promise<{
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { findCommandOnPath } from "./host-detect.js";
|
|
6
|
+
const shimPattern = /\.(?:cmd|bat)$/iu;
|
|
7
|
+
const unquotableCharacters = /["%\r\n\0]/u;
|
|
8
|
+
/** One cmd.exe argument in double quotes. Refuses what quotes cannot protect, so a value never becomes shell syntax. */
|
|
9
|
+
export function quoteForCmd(value) {
|
|
10
|
+
if (unquotableCharacters.test(value))
|
|
11
|
+
throw new Error("A Windows command argument may not contain a quote, a percent sign or a line break.");
|
|
12
|
+
return `"${value.replace(/(\\+)$/u, "$1$1")}"`;
|
|
13
|
+
}
|
|
14
|
+
function resolveCommand(command, context) {
|
|
15
|
+
if (command.includes("/") || command.includes("\\"))
|
|
16
|
+
return command;
|
|
17
|
+
const find = context.find ?? ((name) => findCommandOnPath(name, context.env ?? process.env, "win32"));
|
|
18
|
+
return find(command) ?? command;
|
|
19
|
+
}
|
|
20
|
+
export function commandPlan(command, args, context = {}) {
|
|
21
|
+
if ((context.platform ?? process.platform) !== "win32")
|
|
22
|
+
return { command, args, shell: false };
|
|
23
|
+
const resolved = resolveCommand(command, context);
|
|
24
|
+
if (!shimPattern.test(resolved))
|
|
25
|
+
return { command: resolved, args, shell: false };
|
|
26
|
+
return { command: quoteForCmd(resolved), args: args.map(quoteForCmd), shell: true };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* npm without a `.cmd` shim in the way. Inside OpenCode the plugin runs on a Bun host binary, so
|
|
30
|
+
* `process.execPath` is not Node: npm is found through its shim's own directory instead, and only
|
|
31
|
+
* then through a quoted shell.
|
|
32
|
+
*/
|
|
33
|
+
export function npmPlan(args, context = {}) {
|
|
34
|
+
if ((context.platform ?? process.platform) !== "win32")
|
|
35
|
+
return { command: "npm", args, shell: false };
|
|
36
|
+
const env = context.env ?? process.env;
|
|
37
|
+
const execPath = context.execPath ?? process.execPath;
|
|
38
|
+
const versions = context.versions ?? process.versions;
|
|
39
|
+
const exists = context.exists ?? ((file) => fs.existsSync(file));
|
|
40
|
+
const npmCli = env.npm_execpath;
|
|
41
|
+
if (npmCli !== undefined && /\.[cm]?js$/iu.test(npmCli) && versions.bun === undefined && /^node(?:\.exe)?$/iu.test(path.win32.basename(execPath))) {
|
|
42
|
+
return { command: execPath, args: [npmCli, ...args], shell: false };
|
|
43
|
+
}
|
|
44
|
+
const shim = resolveCommand("npm", context);
|
|
45
|
+
if (shimPattern.test(shim)) {
|
|
46
|
+
const directory = path.win32.dirname(shim);
|
|
47
|
+
const cli = path.win32.join(directory, "node_modules", "npm", "bin", "npm-cli.js");
|
|
48
|
+
if (exists(cli)) {
|
|
49
|
+
const sibling = path.win32.join(directory, "node.exe");
|
|
50
|
+
const node = exists(sibling) ? sibling : resolveCommand("node", context);
|
|
51
|
+
return { command: node, args: [cli, ...args], shell: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return commandPlan("npm", args, { ...context, platform: "win32" });
|
|
55
|
+
}
|
|
56
|
+
const execFileAsync = promisify(execFile);
|
|
57
|
+
/** `execFile` through a CommandPlan; `npm` is resolved by `npmPlan`. A failure keeps the child's stderr and exit code. */
|
|
58
|
+
export async function execCommand(command, args, options = {}) {
|
|
59
|
+
const context = { env: options.env ?? process.env };
|
|
60
|
+
const plan = command === "npm" ? npmPlan(args, context) : commandPlan(command, args, context);
|
|
61
|
+
return await execFileAsync(plan.command, [...plan.args], { ...options, encoding: "utf8", shell: plan.shell, windowsHide: true });
|
|
62
|
+
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CliResult } from "./types.ts";
|
|
2
|
+
import { type HostChoice } from "./host-detect.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Doctor reads the receipt and the record each OpenCode start leaves behind. It
|
|
4
5
|
* cannot see inside a running OpenCode, so "running" means the last start seen.
|
|
@@ -13,4 +14,6 @@ export declare function inspectAutoUpdate(input: {
|
|
|
13
14
|
* directory, and without one the directory itself.
|
|
14
15
|
*/
|
|
15
16
|
export declare function doctorProjectRoot(cwd?: string): string;
|
|
16
|
-
|
|
17
|
+
/** OpenCode 2's update policy for plugins, read from the shared `autoupdate` setting and its opt-out variable. */
|
|
18
|
+
export declare function openCode2UpdatePolicy(config: Record<string, unknown> | null, env?: NodeJS.ProcessEnv): "auto" | "notify" | "off";
|
|
19
|
+
export declare function doctor(root: string, hostChoice?: HostChoice): Promise<CliResult>;
|
package/dist/cli/doctor.js
CHANGED
|
@@ -15,7 +15,9 @@ import { autoHandoffEnvEnabled, autoHandoffEnvPercent, createAutoHandoffSettings
|
|
|
15
15
|
import { inspectLspCapability } from "./lsp-capability.js";
|
|
16
16
|
import { probeMotionRuntime } from "./motion-runtime.js";
|
|
17
17
|
import { isLitOpenCodeEntry } from "./plugin-mutation.js";
|
|
18
|
-
import { inspectTuiPlugin } from "./tui-config.js";
|
|
18
|
+
import { inspectTuiPlugin, planCliJsonCleanup } from "./tui-config.js";
|
|
19
|
+
import { detectOpenCodeHosts, targetsV1, targetsV2 } from "./host-detect.js";
|
|
20
|
+
import { defaultOpenCodeConfigRoot } from "../state.js";
|
|
19
21
|
import { autoUpdateCheckPath, autoUpdateInstallLockPath, autoUpdateJournalPath, autoUpdateReceiptPath, isAutoUpdateDisabled, readAutoUpdateCheck, readAutoUpdateReceipt } from "./auto-update.js";
|
|
20
22
|
const updateNow = "npx @litfamily/litopencode@latest install";
|
|
21
23
|
/** Each row says what a cause means and the one thing to do about it. */
|
|
@@ -162,7 +164,32 @@ export function doctorProjectRoot(cwd = process.cwd()) {
|
|
|
162
164
|
return start;
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
|
-
|
|
167
|
+
/** OpenCode 2's update policy for plugins, read from the shared `autoupdate` setting and its opt-out variable. */
|
|
168
|
+
export function openCode2UpdatePolicy(config, env = process.env) {
|
|
169
|
+
const disabled = env.OPENCODE_DISABLE_AUTOUPDATE;
|
|
170
|
+
if (disabled !== undefined && disabled !== "" && disabled !== "0" && disabled.toLowerCase() !== "false")
|
|
171
|
+
return "off";
|
|
172
|
+
const value = config?.autoupdate;
|
|
173
|
+
if (value === false)
|
|
174
|
+
return "off";
|
|
175
|
+
if (value === "notify")
|
|
176
|
+
return "notify";
|
|
177
|
+
return "auto";
|
|
178
|
+
}
|
|
179
|
+
async function litEntryCount(root, config, packageName) {
|
|
180
|
+
const entries = [
|
|
181
|
+
...(Array.isArray(config?.plugin) ? config.plugin : []),
|
|
182
|
+
...(Array.isArray(config?.plugins) ? config.plugins : [])
|
|
183
|
+
];
|
|
184
|
+
let count = 0;
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
const id = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry.package : pluginEntryId(entry);
|
|
187
|
+
if (isLitOpenCodeEntry(id) || (typeof id === "string" && (await isLocalPackageEntry(root, id, packageName))))
|
|
188
|
+
count += 1;
|
|
189
|
+
}
|
|
190
|
+
return count;
|
|
191
|
+
}
|
|
192
|
+
export async function doctor(root, hostChoice = "auto") {
|
|
166
193
|
const metadata = await readPackageMetadata();
|
|
167
194
|
const loaded = await loadConfig(root);
|
|
168
195
|
const openCodeLitConfigPath = litOpenCodeConfigFileForOpenCodeRoot(root);
|
|
@@ -206,6 +233,25 @@ export async function doctor(root) {
|
|
|
206
233
|
config: autoHandoffConfig.autoHandoff
|
|
207
234
|
}).resolve();
|
|
208
235
|
const hostCompaction = inspectHostCompactionPoint(hostConfig.config);
|
|
236
|
+
// Doctor never starts a host: it reads the npm package behind each command, and on a custom root only
|
|
237
|
+
// that root's config files.
|
|
238
|
+
const defaultRoot = path.resolve(root) === path.resolve(defaultOpenCodeConfigRoot());
|
|
239
|
+
const host = await detectOpenCodeHosts({
|
|
240
|
+
root,
|
|
241
|
+
override: hostChoice,
|
|
242
|
+
...(defaultRoot ? {} : { resolvePackage: async () => undefined })
|
|
243
|
+
});
|
|
244
|
+
const forV1 = targetsV1(host.mode);
|
|
245
|
+
const forV2 = targetsV2(host.mode);
|
|
246
|
+
const pluginEntries = await litEntryCount(root, hostConfig.config, metadata.name);
|
|
247
|
+
const cliJson = await planCliJsonCleanup(root).then((plan) => ({ path: plan.path, stale: plan.removed }), (error) => ({ path: path.join(path.resolve(root), "cli.json"), stale: 0, error: error instanceof Error ? error.message : String(error) }));
|
|
248
|
+
const hostOnlyPresent = ["permissions", "policies"].filter((key) => hostConfig.config !== null && key in hostConfig.config);
|
|
249
|
+
const hostOnlyKeys = {
|
|
250
|
+
present: hostOnlyPresent,
|
|
251
|
+
breaksOpenCode1: forV1 && hostOnlyPresent.includes("permissions"),
|
|
252
|
+
note: "OpenCode 1 refuses to start when the shared opencode.json holds the OpenCode 2 `permissions` key. LitOpenCode never writes `permissions` or `policies`."
|
|
253
|
+
};
|
|
254
|
+
const openCode2Ok = !forV2 || (pluginEntries === 1 && cliJson.stale === 0);
|
|
209
255
|
const autoHandoffWarnings = [...autoHandoff.warnings];
|
|
210
256
|
if (autoHandoff.enabled && autoHandoff.percent !== null && hostCompaction.percent !== null && autoHandoff.percent >= hostCompaction.percent) {
|
|
211
257
|
autoHandoffWarnings.push(`OpenCode compacts on its own at about ${hostCompaction.percent}% of the context window. Your ${autoHandoff.percent}% is at or above that, so the host compacts first and the handoff has no time to run. Choose a percent below ${hostCompaction.percent}.`);
|
|
@@ -216,7 +262,19 @@ export async function doctor(root) {
|
|
|
216
262
|
package: metadata,
|
|
217
263
|
capabilities: openCodeHostCapabilities(),
|
|
218
264
|
hostLimits: { path: hostConfig.path, ...inspectHostLimits(hostConfig.config) },
|
|
219
|
-
|
|
265
|
+
host: {
|
|
266
|
+
mode: host.mode,
|
|
267
|
+
binaries: host.binaries,
|
|
268
|
+
...(host.unplaced === undefined ? {} : { unplaced: host.unplaced }),
|
|
269
|
+
evidence: host.evidence,
|
|
270
|
+
...(host.override === undefined ? {} : { override: host.override }),
|
|
271
|
+
updatePolicy: forV2 ? openCode2UpdatePolicy(hostConfig.config) : null,
|
|
272
|
+
note: "OpenCode 1 runs as `opencode` and OpenCode 2 as `opencode2` when both are installed; they share this config folder."
|
|
273
|
+
},
|
|
274
|
+
lsp: {
|
|
275
|
+
...inspectLspCapability(hostConfig.config),
|
|
276
|
+
...(forV2 ? { openCode2: "OpenCode 2 accepts the lsp settings but does not run language servers, so it reports no diagnostics from them." } : {})
|
|
277
|
+
},
|
|
220
278
|
autoHandoff: {
|
|
221
279
|
enabled: autoHandoff.enabled,
|
|
222
280
|
percent: autoHandoff.percent,
|
|
@@ -273,8 +331,11 @@ export async function doctor(root) {
|
|
|
273
331
|
pinnedVersion: installPlugin.configured?.startsWith(`${metadata.name}@`) === true ? installPlugin.configured.slice(metadata.name.length + 1) : null
|
|
274
332
|
}),
|
|
275
333
|
install: {
|
|
276
|
-
ok: installPlugin.ok && commandAliases.ok && nativeSkills.ok,
|
|
334
|
+
ok: installPlugin.ok && commandAliases.ok && nativeSkills.ok && openCode2Ok && !hostOnlyKeys.breaksOpenCode1,
|
|
277
335
|
plugin: installPlugin,
|
|
336
|
+
pluginEntries,
|
|
337
|
+
cliJson,
|
|
338
|
+
hostOnlyKeys,
|
|
278
339
|
commandAliases,
|
|
279
340
|
nativeSkills,
|
|
280
341
|
tui
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare const hostChoices: readonly ["auto", "v1", "v2", "both"];
|
|
2
|
+
export type HostChoice = (typeof hostChoices)[number];
|
|
3
|
+
export type HostMode = "v1" | "v2" | "both" | "none";
|
|
4
|
+
export type HostMajor = 1 | 2;
|
|
5
|
+
export type HostCommand = "opencode" | "opencode2";
|
|
6
|
+
export type HostBinary = {
|
|
7
|
+
readonly command: HostCommand;
|
|
8
|
+
readonly major: HostMajor;
|
|
9
|
+
readonly version: string;
|
|
10
|
+
};
|
|
11
|
+
export type HostDetection = {
|
|
12
|
+
readonly mode: HostMode;
|
|
13
|
+
readonly binaries: readonly HostBinary[];
|
|
14
|
+
/** Commands on PATH with no npm package behind them, and the host each one was taken for. */
|
|
15
|
+
readonly unplaced?: readonly {
|
|
16
|
+
readonly command: HostCommand;
|
|
17
|
+
readonly major: HostMajor;
|
|
18
|
+
}[];
|
|
19
|
+
readonly evidence: {
|
|
20
|
+
readonly cliJson: boolean;
|
|
21
|
+
readonly tuiJson: boolean;
|
|
22
|
+
};
|
|
23
|
+
readonly v1Command?: string;
|
|
24
|
+
readonly v2Command?: string;
|
|
25
|
+
readonly override?: Exclude<HostChoice, "auto">;
|
|
26
|
+
};
|
|
27
|
+
export type PackageLike = {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly version: string;
|
|
30
|
+
};
|
|
31
|
+
export type HostDetectOptions = {
|
|
32
|
+
readonly root: string;
|
|
33
|
+
/** Injected package lookup; with it, PATH is only read through `findCommand`. */
|
|
34
|
+
readonly resolvePackage?: (command: string) => Promise<PackageLike | undefined>;
|
|
35
|
+
/** The real path of a command on PATH, or undefined. */
|
|
36
|
+
readonly findCommand?: (command: string) => string | undefined;
|
|
37
|
+
readonly override?: HostChoice;
|
|
38
|
+
};
|
|
39
|
+
type PathEnv = {
|
|
40
|
+
readonly PATH?: string;
|
|
41
|
+
readonly PATHEXT?: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function isHostChoice(value: string): value is HostChoice;
|
|
44
|
+
/** A command on PATH. On Windows the PATHEXT extensions are tried too, so an npm `opencode.cmd` is found. */
|
|
45
|
+
export declare function findCommandOnPath(command: string, env?: PathEnv, platform?: NodeJS.Platform): string | undefined;
|
|
46
|
+
/** The npm package that provides a command on PATH, found by following its link to the package folder. */
|
|
47
|
+
export declare function resolveCommandPackage(command: string, env?: PathEnv, platform?: NodeJS.Platform): Promise<PackageLike | undefined>;
|
|
48
|
+
export declare function detectOpenCodeHosts(options: HostDetectOptions): Promise<HostDetection>;
|
|
49
|
+
/**
|
|
50
|
+
* Confirms an `opencode` that was only guessed to be OpenCode 1 (no npm package behind it) by asking its
|
|
51
|
+
* version, so `opencode plugin` never runs on OpenCode 2. A 2.x answer makes it OpenCode 2; no readable
|
|
52
|
+
* answer keeps the guess. Runs nothing when there is no guess to confirm.
|
|
53
|
+
*/
|
|
54
|
+
export declare function confirmUnplacedOpenCode(detection: HostDetection, readVersion: (command: string) => Promise<string | undefined>): Promise<HostDetection>;
|
|
55
|
+
/** Whether a detection result means the OpenCode 1 or the OpenCode 2 surfaces must be installed. A host-less result keeps the OpenCode 1 behaviour. */
|
|
56
|
+
export declare function targetsV1(mode: HostMode): boolean;
|
|
57
|
+
export declare function targetsV2(mode: HostMode): boolean;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Which OpenCode hosts are installed. OpenCode 1 ships as the npm package `opencode-ai` with the
|
|
2
|
+
// `opencode` command; OpenCode 2 ships as `@opencode/cli` with both `opencode` and `opencode2`. When both
|
|
3
|
+
// are kept, OpenCode 1 stays `opencode` and OpenCode 2 is run as `opencode2`; they share
|
|
4
|
+
// ~/.config/opencode. Detection never starts a host: it reads the npm package behind each command on
|
|
5
|
+
// PATH. Only OpenCode 2 ships `opencode2`, so any `opencode2` on PATH is OpenCode 2. An `opencode` it
|
|
6
|
+
// cannot place (the curl installer, Homebrew, mise, a Windows shim it cannot read) is taken for OpenCode 1,
|
|
7
|
+
// because OpenCode 1 keeps its install however it was installed, unless it is the same file as
|
|
8
|
+
// `opencode2`. That guess is confirmed by `confirmUnplacedOpenCode` (which runs `opencode --version`)
|
|
9
|
+
// before the installer runs `opencode plugin` on it. With no command on PATH, config files are the
|
|
10
|
+
// evidence: cli.json is written only by OpenCode 2, tui.json is OpenCode 1's TUI config. `--host`
|
|
11
|
+
// overrides the result.
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
export const hostChoices = ["auto", "v1", "v2", "both"];
|
|
15
|
+
const commands = ["opencode", "opencode2"];
|
|
16
|
+
const hostPackages = ["opencode-ai", "@opencode/cli"];
|
|
17
|
+
const shimLimitBytes = 64 * 1024;
|
|
18
|
+
export function isHostChoice(value) {
|
|
19
|
+
return hostChoices.includes(value);
|
|
20
|
+
}
|
|
21
|
+
function packageMajor(pkg) {
|
|
22
|
+
if (pkg.name === "opencode-ai")
|
|
23
|
+
return 1;
|
|
24
|
+
if (pkg.name === "@opencode/cli")
|
|
25
|
+
return 2;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/** A command on PATH. On Windows the PATHEXT extensions are tried too, so an npm `opencode.cmd` is found. */
|
|
29
|
+
export function findCommandOnPath(command, env = process.env, platform = process.platform) {
|
|
30
|
+
const windows = platform === "win32";
|
|
31
|
+
const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => ext !== "").map((ext) => ext.toLowerCase());
|
|
32
|
+
// npm leaves an extensionless sh script next to `x.cmd`; Windows never runs it, so a bare name matches only with an extension.
|
|
33
|
+
const names = windows
|
|
34
|
+
? [...(extensions.some((ext) => command.toLowerCase().endsWith(ext)) ? [command] : []), ...extensions.map((ext) => command + ext)]
|
|
35
|
+
: [command];
|
|
36
|
+
for (const directory of (env.PATH ?? "").split(windows ? ";" : path.delimiter)) {
|
|
37
|
+
if (directory === "")
|
|
38
|
+
continue;
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
const candidate = path.join(directory, name);
|
|
41
|
+
try {
|
|
42
|
+
fs.accessSync(candidate, windows ? fs.constants.F_OK : fs.constants.X_OK);
|
|
43
|
+
if (fs.statSync(candidate).isFile())
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
function readPackageJson(file) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
56
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.name === "string") {
|
|
57
|
+
const { name, version } = parsed;
|
|
58
|
+
return { name, version: typeof version === "string" ? version : "" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// No readable package.json here.
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* npm on Windows puts a small `.cmd`/`.ps1` shim next to its `node_modules` instead of a link. The shim is
|
|
68
|
+
* read as text, never run, for the host package path it names.
|
|
69
|
+
*/
|
|
70
|
+
function packageFromShim(file) {
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
if (fs.statSync(file).size > shimLimitBytes)
|
|
74
|
+
return undefined;
|
|
75
|
+
text = fs.readFileSync(file, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const normalized = text.replace(/\\/gu, "/");
|
|
81
|
+
for (const name of hostPackages) {
|
|
82
|
+
if (!normalized.includes(`node_modules/${name}/`))
|
|
83
|
+
continue;
|
|
84
|
+
return readPackageJson(path.join(path.dirname(file), "node_modules", ...name.split("/"), "package.json")) ?? { name, version: "" };
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/** The npm package that provides a command on PATH, found by following its link to the package folder. */
|
|
89
|
+
export async function resolveCommandPackage(command, env = process.env, platform = process.platform) {
|
|
90
|
+
const found = findCommandOnPath(command, env, platform);
|
|
91
|
+
if (found === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
let directory;
|
|
94
|
+
try {
|
|
95
|
+
directory = path.dirname(fs.realpathSync(found));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
101
|
+
// Only a host package counts: on Windows the walk starts in the npm folder, where an unrelated
|
|
102
|
+
// package.json further up must not hide the package the shim names.
|
|
103
|
+
const pkg = readPackageJson(path.join(directory, "package.json"));
|
|
104
|
+
if (pkg !== undefined && hostPackages.includes(pkg.name))
|
|
105
|
+
return pkg;
|
|
106
|
+
const parent = path.dirname(directory);
|
|
107
|
+
if (parent === directory)
|
|
108
|
+
break;
|
|
109
|
+
directory = parent;
|
|
110
|
+
}
|
|
111
|
+
return packageFromShim(found);
|
|
112
|
+
}
|
|
113
|
+
function realCommandPath(command) {
|
|
114
|
+
const found = findCommandOnPath(command);
|
|
115
|
+
if (found === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
return fs.realpathSync(found);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function probe(command, options) {
|
|
125
|
+
const pkg = await (options.resolvePackage ?? resolveCommandPackage)(command);
|
|
126
|
+
const major = pkg === undefined ? undefined : packageMajor(pkg);
|
|
127
|
+
return pkg === undefined || major === undefined ? undefined : { command, major, version: pkg.version };
|
|
128
|
+
}
|
|
129
|
+
function modeOf(v1, v2) {
|
|
130
|
+
if (v1 && v2)
|
|
131
|
+
return "both";
|
|
132
|
+
if (v1)
|
|
133
|
+
return "v1";
|
|
134
|
+
if (v2)
|
|
135
|
+
return "v2";
|
|
136
|
+
return "none";
|
|
137
|
+
}
|
|
138
|
+
function summarize(binaries, unplaced, evidence, override) {
|
|
139
|
+
const hosts = [...binaries, ...unplaced];
|
|
140
|
+
const v1Command = hosts.find((host) => host.major === 1)?.command;
|
|
141
|
+
const v2Command = hosts.find((host) => host.major === 2)?.command;
|
|
142
|
+
const detected = hosts.length > 0 ? modeOf(v1Command !== undefined, v2Command !== undefined) : modeOf(evidence.tuiJson, evidence.cliJson);
|
|
143
|
+
return {
|
|
144
|
+
mode: override ?? detected,
|
|
145
|
+
binaries,
|
|
146
|
+
...(unplaced.length === 0 ? {} : { unplaced }),
|
|
147
|
+
evidence,
|
|
148
|
+
...(v1Command === undefined ? {} : { v1Command }),
|
|
149
|
+
...(v2Command === undefined ? {} : { v2Command }),
|
|
150
|
+
...(override === undefined ? {} : { override })
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export async function detectOpenCodeHosts(options) {
|
|
154
|
+
const binaries = [];
|
|
155
|
+
for (const command of commands) {
|
|
156
|
+
const binary = await probe(command, options);
|
|
157
|
+
if (binary !== undefined)
|
|
158
|
+
binaries.push(binary);
|
|
159
|
+
}
|
|
160
|
+
const findCommand = options.findCommand ?? (options.resolvePackage === undefined ? realCommandPath : () => undefined);
|
|
161
|
+
const placed = (command) => binaries.some((binary) => binary.command === command);
|
|
162
|
+
const opencodeFile = placed("opencode") ? undefined : findCommand("opencode");
|
|
163
|
+
const opencode2File = placed("opencode2") ? undefined : findCommand("opencode2");
|
|
164
|
+
const unplaced = [];
|
|
165
|
+
if (opencodeFile !== undefined) {
|
|
166
|
+
const sameAsOpenCode2 = findCommand("opencode2") === opencodeFile;
|
|
167
|
+
unplaced.push({ command: "opencode", major: sameAsOpenCode2 ? 2 : 1 });
|
|
168
|
+
}
|
|
169
|
+
if (opencode2File !== undefined)
|
|
170
|
+
unplaced.push({ command: "opencode2", major: 2 });
|
|
171
|
+
const evidence = {
|
|
172
|
+
cliJson: fs.existsSync(path.join(options.root, "cli.json")),
|
|
173
|
+
tuiJson: fs.existsSync(path.join(options.root, "tui.json")) || fs.existsSync(path.join(options.root, "tui.jsonc"))
|
|
174
|
+
};
|
|
175
|
+
const override = options.override === undefined || options.override === "auto" ? undefined : options.override;
|
|
176
|
+
return summarize(binaries, unplaced, evidence, override);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Confirms an `opencode` that was only guessed to be OpenCode 1 (no npm package behind it) by asking its
|
|
180
|
+
* version, so `opencode plugin` never runs on OpenCode 2. A 2.x answer makes it OpenCode 2; no readable
|
|
181
|
+
* answer keeps the guess. Runs nothing when there is no guess to confirm.
|
|
182
|
+
*/
|
|
183
|
+
export async function confirmUnplacedOpenCode(detection, readVersion) {
|
|
184
|
+
const guess = detection.unplaced?.find((entry) => entry.command === "opencode" && entry.major === 1);
|
|
185
|
+
if (guess === undefined)
|
|
186
|
+
return detection;
|
|
187
|
+
const major = /(\d+)\.\d+\.\d+/u.exec((await readVersion(guess.command)) ?? "")?.[1];
|
|
188
|
+
if (major !== "2")
|
|
189
|
+
return detection;
|
|
190
|
+
const unplaced = (detection.unplaced ?? []).map((entry) => (entry === guess ? { command: entry.command, major: 2 } : entry));
|
|
191
|
+
return summarize(detection.binaries, unplaced, detection.evidence, detection.override);
|
|
192
|
+
}
|
|
193
|
+
/** Whether a detection result means the OpenCode 1 or the OpenCode 2 surfaces must be installed. A host-less result keeps the OpenCode 1 behaviour. */
|
|
194
|
+
export function targetsV1(mode) {
|
|
195
|
+
return mode === "v1" || mode === "both" || mode === "none";
|
|
196
|
+
}
|
|
197
|
+
export function targetsV2(mode) {
|
|
198
|
+
return mode === "v2" || mode === "both";
|
|
199
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { targetsV1 } from "./host-detect.js";
|
|
1
2
|
import { shippedDefaultsSummary } from "./model-catalog.js";
|
|
2
3
|
const reset = "\u001b[0m";
|
|
3
4
|
const green = "\u001b[38;5;82m";
|
|
@@ -191,7 +192,8 @@ export function renderInstallReport(report) {
|
|
|
191
192
|
line("Native skills", report.nativeSkills.write.length + " managed / " + report.nativeSkills.preserve.length + " preserved"),
|
|
192
193
|
...nativeSkillsLinkLines(report),
|
|
193
194
|
line("Plugin entries", report.plugin.currentCount + " -> " + report.plugin.resultCount),
|
|
194
|
-
|
|
195
|
+
// OpenCode 2 loads the TUI with the server plugin, so an OpenCode 2-only install has no TUI file.
|
|
196
|
+
...(report.host === undefined || targetsV1(report.host.mode) ? [line("TUI config", report.tui.path)] : []),
|
|
195
197
|
...section("Progress"),
|
|
196
198
|
progressLine("Resolve package"),
|
|
197
199
|
progressLine("Read OpenCode config"),
|