Skip to content

Commit b5ea8ec

Browse files
authored
Merge branch 'main' into ci/version-bump-0.0.4
2 parents 00d569b + f608fb8 commit b5ea8ec

File tree

4 files changed

+219
-17
lines changed

4 files changed

+219
-17
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ bun run start -- --no-open
185185
- Auto-installs missing `git` and `node/npm` inside sandbox
186186
- Forwards provider env vars (`OPENAI_*`, `ANTHROPIC_*`, `XAI_*`, `OPENROUTER_*`, `ZHIPU_*`, `MINIMAX_*`, etc.)
187187
- Syncs local OpenCode config files from `~/.config/opencode` when present
188+
- Syncs local OpenCode OAuth auth file (`~/.local/share/opencode/auth.json`) into sandbox with `chmod 600` when present
189+
- When using `anthropic/*` models, runs `opencode models anthropic` preflight inside sandbox and fails early if the requested model is unavailable
190+
- Produces more skimmable reports with concise summary bullets, sentence-fragment-friendly style, and an ASCII logic/data-flow diagram section
188191
- Uses a fixed Daytona lifecycle policy: auto-stop after 15 minutes, auto-archive after 30 minutes, auto-delete disabled
189192
- Auto-catalogs findings into Obsidian when enabled via `shpit.toml`, with optional automatic `ob sync` in headless mode
190193

@@ -208,6 +211,7 @@ If no URLs and no `--input` are provided, the script uses `example.md` when it e
208211
- `<out-dir>/<NN-slug>/findings.md` - final report for each repository
209212
- `<out-dir>/<NN-slug>/README.*` - copied repository README (if found)
210213
- `<out-dir>/<NN-slug>/opencode-run.log` - raw OpenCode run output
214+
- `<out-dir>/<NN-slug>/opencode-models-anthropic.log` - Anthropic model-list preflight output (only when `anthropic/*` model is requested)
211215

212216
Example retained in this repo:
213217

src/analyze-repos.ts

Lines changed: 176 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { parseArgs } from "node:util";
55
import { Daytona, type Sandbox } from "@daytonaio/sdk";
66
import { resolveAnalyzeModel } from "./analyze-model.js";
77
import { catalogAnalysisResult } from "./obsidian-catalog.js";
8-
import { buildInstallOpencodeCommand, buildOpencodeRunCommand } from "./opencode-cli.js";
8+
import {
9+
buildInstallOpencodeCommand,
10+
buildOpencodeModelsCommand,
11+
buildOpencodeRunCommand,
12+
} from "./opencode-cli.js";
913
import { loadConfiguredEnv, type ResolvedShpitConfig, resolveShpitConfig } from "./shpit-config.js";
1014

1115
type CliOptions = {
@@ -175,6 +179,52 @@ async function getExistingLocalOpencodeConfigFiles(): Promise<string[]> {
175179
return existing;
176180
}
177181

182+
function getLocalOpencodeAuthCandidates(): string[] {
183+
const home = process.env.HOME;
184+
if (!home) {
185+
return [];
186+
}
187+
return [path.join(home, ".local", "share", "opencode", "auth.json")];
188+
}
189+
190+
async function getExistingLocalOpencodeAuthFiles(): Promise<string[]> {
191+
const candidates = getLocalOpencodeAuthCandidates();
192+
const existing: string[] = [];
193+
for (const candidate of candidates) {
194+
if (await fileExists(candidate)) {
195+
existing.push(candidate);
196+
}
197+
}
198+
return existing;
199+
}
200+
201+
function modelProvider(modelId: string): string | undefined {
202+
const slashIndex = modelId.indexOf("/");
203+
if (slashIndex <= 0) {
204+
return undefined;
205+
}
206+
return modelId.slice(0, slashIndex).toLowerCase();
207+
}
208+
209+
function parseModelListOutput(output: string): string[] {
210+
return output
211+
.split(/\r?\n/)
212+
.map((line) => line.trim())
213+
.filter((line) => /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9._-]*$/i.test(line));
214+
}
215+
216+
function normalizeModelId(modelId: string): string {
217+
return modelId.trim().toLowerCase().replace(/[._]/g, "-");
218+
}
219+
220+
function findNormalizedModelMatch(
221+
requestedModel: string,
222+
availableModels: string[],
223+
): string | undefined {
224+
const normalizedRequested = normalizeModelId(requestedModel);
225+
return availableModels.find((model) => normalizeModelId(model) === normalizedRequested);
226+
}
227+
178228
function parseCliOptions(): CliOptions {
179229
const { values, positionals } = parseArgs({
180230
options: {
@@ -357,6 +407,7 @@ function detectOpencodeFatalError(output: string): string | undefined {
357407
/API key is missing/i,
358408
/AI_LoadAPIKeyError/i,
359409
/AuthenticationError/i,
410+
/Token refresh failed/i,
360411
];
361412

362413
for (const line of lines) {
@@ -370,7 +421,10 @@ function detectOpencodeFatalError(output: string): string | undefined {
370421
}
371422

372423
function hasReadyResponse(output: string): boolean {
373-
return /\bready\b/i.test(output);
424+
return output
425+
.split(/\r?\n/)
426+
.map((line) => line.trim().toLowerCase())
427+
.some((line) => line === "ready");
374428
}
375429

376430
async function withRetries<T>(params: {
@@ -576,23 +630,29 @@ function buildAnalysisPrompt(params: { inputUrl: string; reportPath: string }):
576630
"1. Input URL",
577631
"2. Claimed Purpose (from README)",
578632
"3. Reality Check Summary",
633+
" - Keep this very concise and easy to scan (4-7 bullets max).",
634+
" - Sentence fragments are allowed and encouraged when clearer.",
579635
"4. More Accurate Title + Description",
580-
"5. Functionality Breakdown (logically grouped)",
636+
"5. How It Works (logic walkthrough)",
637+
" - Explain the end-to-end flow in plain terms.",
638+
" - Include at least one ASCII diagram that maps key components and data flow.",
639+
"6. Functionality Breakdown (logically grouped)",
581640
" - For each group: what exists, what's solid, what's partial/sloppy, with file-path evidence.",
582-
"6. Runtime Validation",
641+
"7. Runtime Validation",
583642
" - Commands run, key logs/output, blockers.",
584-
"7. Quality Assessment",
643+
"8. Quality Assessment",
585644
" - correctness, maintainability, test quality, production-readiness risks.",
586-
"8. Usefulness & Value Judgment",
645+
"9. Usefulness & Value Judgment",
587646
" - who should use it, who should not, where it is valuable.",
588-
"9. Better Alternatives",
647+
"10. Better Alternatives",
589648
" - at least 3 alternatives with links and why they are better for specific scenarios.",
590-
"10. Final Verdict",
649+
"11. Final Verdict",
591650
" - completeness score (0-10) and practical value score (0-10), with rationale.",
592651
"",
593652
"Constraints:",
594653
"- Be direct and specific.",
595-
"- Use short bullet lists where appropriate.",
654+
"- Prefer concise bullets over long prose paragraphs.",
655+
"- Sentence fragments are acceptable.",
596656
"- If something cannot be validated, state it explicitly.",
597657
"- Save only the final report file.",
598658
].join("\n");
@@ -637,6 +697,7 @@ async function analyzeOneRepo(params: {
637697
const remoteRepoDir = `${userHome}/audit/${slug}`;
638698
const remoteReportPath = `${remoteRepoDir}/.opencode-audit-findings.md`;
639699
const remoteOpencodeConfigDir = `${userHome}/.config/opencode`;
700+
const remoteOpencodeShareDir = `${userHome}/.local/share/opencode`;
640701

641702
const localOpencodeConfigFiles = await getExistingLocalOpencodeConfigFiles();
642703
if (localOpencodeConfigFiles.length > 0) {
@@ -659,6 +720,37 @@ async function analyzeOneRepo(params: {
659720
);
660721
}
661722

723+
const localOpencodeAuthFiles = await getExistingLocalOpencodeAuthFiles();
724+
if (localOpencodeAuthFiles.length > 0) {
725+
await requireSuccess(
726+
sandbox,
727+
`mkdir -p ${shellEscape(remoteOpencodeShareDir)} && chmod 700 ${shellEscape(remoteOpencodeShareDir)}`,
728+
"Create OpenCode auth directory",
729+
30,
730+
);
731+
732+
for (const localAuthFile of localOpencodeAuthFiles) {
733+
const remoteAuthFile = `${remoteOpencodeShareDir}/${path.basename(localAuthFile)}`;
734+
await uploadFileWithRetries(sandbox, localAuthFile, remoteAuthFile);
735+
await requireSuccess(
736+
sandbox,
737+
`chmod 600 ${shellEscape(remoteAuthFile)}`,
738+
`Harden ${path.basename(localAuthFile)} permissions`,
739+
30,
740+
);
741+
}
742+
743+
console.log(
744+
`[analyze] (${runPrefix}) Synced ${localOpencodeAuthFiles.length} local OpenCode auth file(s) into sandbox.`,
745+
);
746+
747+
if (options.keepSandbox) {
748+
console.warn(
749+
`[analyze] (${runPrefix}) OAuth auth files were synced and --keep-sandbox is enabled. Remove ${remoteOpencodeShareDir}/auth.json when done.`,
750+
);
751+
}
752+
}
753+
662754
await requireSuccess(sandbox, buildEnsureGitCommand(), "Ensure git", options.installTimeoutSec);
663755
await requireSuccess(
664756
sandbox,
@@ -695,20 +787,91 @@ async function analyzeOneRepo(params: {
695787
const hasProviderCredential = forwardedEnvEntries.some(
696788
([name]) => !name.startsWith("OPENCODE_"),
697789
);
698-
if (!hasProviderCredential) {
790+
const hasOAuthAuthFile = localOpencodeAuthFiles.length > 0;
791+
if (!hasProviderCredential && !hasOAuthAuthFile) {
699792
console.warn(
700-
`[analyze] (${runPrefix}) No model provider env vars detected locally (OPENAI_*, ANTHROPIC_*, ZHIPU_*, etc). OpenCode may fail or block.`,
793+
`[analyze] (${runPrefix}) No model provider env vars or local OAuth auth file detected. OpenCode may fail or block.`,
701794
);
702795
}
703796
const selectedModel = resolveAnalyzeModel({
704797
model: options.model,
705798
variant: options.variant,
706799
vision: options.vision,
707800
});
801+
let resolvedModelId = selectedModel.model;
802+
const requestedProvider = modelProvider(selectedModel.model);
803+
const hasAnthropicEnvCredential = forwardedEnvEntries.some(([name]) =>
804+
name.startsWith("ANTHROPIC_"),
805+
);
806+
807+
if (
808+
requestedProvider === "anthropic" &&
809+
!hasAnthropicEnvCredential &&
810+
localOpencodeAuthFiles.length === 0
811+
) {
812+
throw new Error(
813+
"Anthropic model selected, but no ANTHROPIC_* env var or ~/.local/share/opencode/auth.json was found to authenticate inside the sandbox.",
814+
);
815+
}
816+
708817
console.log(
709818
`[analyze] (${runPrefix}) Model: ${selectedModel.model}${selectedModel.variant ? ` (variant: ${selectedModel.variant})` : ""}`,
710819
);
711820

821+
if (requestedProvider === "anthropic") {
822+
console.log(`[analyze] (${runPrefix}) Verifying Anthropic provider access...`);
823+
const anthropicModelsCommand = buildOpencodeModelsCommand({
824+
resolveOpencodeBinCommand: resolveOpencodeBin,
825+
provider: "anthropic",
826+
forwardedEnvEntries,
827+
});
828+
const anthropicModelsResult = await runCommand(sandbox, anthropicModelsCommand, 120);
829+
const anthropicModelsOutput = anthropicModelsResult.output;
830+
await writeFile(
831+
path.join(localDir, "opencode-models-anthropic.log"),
832+
anthropicModelsOutput,
833+
"utf8",
834+
);
835+
836+
if (anthropicModelsResult.exitCode !== 0) {
837+
const preview = anthropicModelsOutput.split("\n").slice(-120).join("\n");
838+
throw new Error(
839+
`Anthropic provider preflight failed (exit code ${anthropicModelsResult.exitCode}).\n${preview}`,
840+
);
841+
}
842+
843+
const availableAnthropicModels = parseModelListOutput(anthropicModelsOutput).filter(
844+
(model) => modelProvider(model) === "anthropic",
845+
);
846+
847+
if (availableAnthropicModels.length === 0) {
848+
throw new Error(
849+
"Anthropic provider preflight returned no models. Verify OpenCode OAuth session and retry.",
850+
);
851+
}
852+
853+
const hasExactMatch = availableAnthropicModels.some(
854+
(model) => model.toLowerCase() === selectedModel.model.toLowerCase(),
855+
);
856+
if (!hasExactMatch) {
857+
const normalizedMatch = findNormalizedModelMatch(
858+
selectedModel.model,
859+
availableAnthropicModels,
860+
);
861+
if (normalizedMatch) {
862+
resolvedModelId = normalizedMatch;
863+
console.warn(
864+
`[analyze] (${runPrefix}) Requested model ${selectedModel.model} not found exactly. Using ${normalizedMatch} from available Anthropic models.`,
865+
);
866+
} else {
867+
const preview = availableAnthropicModels.slice(0, 12).join(", ");
868+
throw new Error(
869+
`Requested Anthropic model ${selectedModel.model} is unavailable in sandbox auth context. Available examples: ${preview}`,
870+
);
871+
}
872+
}
873+
}
874+
712875
const preflightPrompt = "Reply with exactly one word: ready";
713876
const preflightTimeoutSec = Math.max(
714877
90,
@@ -718,7 +881,7 @@ async function analyzeOneRepo(params: {
718881
resolveOpencodeBinCommand: resolveOpencodeBin,
719882
workingDir: remoteRepoDir,
720883
prompt: preflightPrompt,
721-
model: selectedModel.model,
884+
model: resolvedModelId,
722885
variant: selectedModel.variant,
723886
forwardedEnvEntries,
724887
});
@@ -743,7 +906,7 @@ async function analyzeOneRepo(params: {
743906
resolveOpencodeBinCommand: resolveOpencodeBin,
744907
workingDir: remoteRepoDir,
745908
prompt,
746-
model: selectedModel.model,
909+
model: resolvedModelId,
747910
variant: selectedModel.variant,
748911
forwardedEnvEntries,
749912
});

src/opencode-cli.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, test } from "bun:test";
2-
import { buildInstallOpencodeCommand, buildOpencodeRunCommand } from "./opencode-cli.js";
2+
import {
3+
buildInstallOpencodeCommand,
4+
buildOpencodeModelsCommand,
5+
buildOpencodeRunCommand,
6+
} from "./opencode-cli.js";
37

48
describe("buildInstallOpencodeCommand", () => {
59
test("tries bun first and falls back to npm", () => {
@@ -34,3 +38,17 @@ describe("buildOpencodeRunCommand", () => {
3438
expect(command).not.toContain("--dir");
3539
});
3640
});
41+
42+
describe("buildOpencodeModelsCommand", () => {
43+
test("builds provider model-list command with forwarded env", () => {
44+
const command = buildOpencodeModelsCommand({
45+
resolveOpencodeBinCommand: "command -v opencode",
46+
provider: "anthropic",
47+
forwardedEnvEntries: [["OPENROUTER_API_KEY", "or-key"]],
48+
});
49+
50+
expect(command).toContain('OPENCODE_BIN="$(command -v opencode)"');
51+
expect(command).toContain("env OPENROUTER_API_KEY='or-key'");
52+
expect(command).toContain("\"$OPENCODE_BIN\" models 'anthropic'");
53+
});
54+
});

src/opencode-cli.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,20 @@ type BuildRunCommandInput = {
77
forwardedEnvEntries?: Array<[string, string]>;
88
};
99

10+
type BuildModelsCommandInput = {
11+
resolveOpencodeBinCommand: string;
12+
provider: string;
13+
forwardedEnvEntries?: Array<[string, string]>;
14+
};
15+
1016
function shellEscape(value: string): string {
1117
return `'${value.replace(/'/g, `'"'"'`)}'`;
1218
}
1319

20+
function buildForwardedEnvArgs(entries: Array<[string, string]> | undefined): string {
21+
return (entries ?? []).map(([name, value]) => `${name}=${shellEscape(value)}`).join(" ");
22+
}
23+
1424
export function buildInstallOpencodeCommand(): string {
1525
return [
1626
"if command -v bun >/dev/null 2>&1; then",
@@ -25,9 +35,7 @@ export function buildInstallOpencodeCommand(): string {
2535
export function buildOpencodeRunCommand(input: BuildRunCommandInput): string {
2636
const modelArg = ` --model ${shellEscape(input.model)}`;
2737
const variantArg = input.variant ? ` --variant ${shellEscape(input.variant)}` : "";
28-
const forwardedEnvArgs = (input.forwardedEnvEntries ?? [])
29-
.map(([name, value]) => `${name}=${shellEscape(value)}`)
30-
.join(" ");
38+
const forwardedEnvArgs = buildForwardedEnvArgs(input.forwardedEnvEntries);
3139

3240
return (
3341
`OPENCODE_BIN="$(${input.resolveOpencodeBinCommand})"; ` +
@@ -36,3 +44,12 @@ export function buildOpencodeRunCommand(input: BuildRunCommandInput): string {
3644
`"${"$"}OPENCODE_BIN" run --print-logs${modelArg}${variantArg} ${shellEscape(input.prompt)}`
3745
);
3846
}
47+
48+
export function buildOpencodeModelsCommand(input: BuildModelsCommandInput): string {
49+
const forwardedEnvArgs = buildForwardedEnvArgs(input.forwardedEnvEntries);
50+
return (
51+
`OPENCODE_BIN="$(${input.resolveOpencodeBinCommand})"; ` +
52+
`${forwardedEnvArgs ? `env ${forwardedEnvArgs} ` : ""}` +
53+
`"${"$"}OPENCODE_BIN" models ${shellEscape(input.provider)}`
54+
);
55+
}

0 commit comments

Comments
 (0)