@@ -5,7 +5,11 @@ import { parseArgs } from "node:util";
55import { Daytona , type Sandbox } from "@daytonaio/sdk" ;
66import { resolveAnalyzeModel } from "./analyze-model.js" ;
77import { 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" ;
913import { loadConfiguredEnv , type ResolvedShpitConfig , resolveShpitConfig } from "./shpit-config.js" ;
1014
1115type 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 - z 0 - 9 ] [ a - z 0 - 9 - ] * \/ [ a - z 0 - 9 ] [ a - z 0 - 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+
178228function parseCliOptions ( ) : CliOptions {
179229 const { values, positionals } = parseArgs ( {
180230 options : {
@@ -357,6 +407,7 @@ function detectOpencodeFatalError(output: string): string | undefined {
357407 / A P I k e y i s m i s s i n g / i,
358408 / A I _ L o a d A P I K e y E r r o r / i,
359409 / A u t h e n t i c a t i o n E r r o r / i,
410+ / T o k e n r e f r e s h f a i l e d / i,
360411 ] ;
361412
362413 for ( const line of lines ) {
@@ -370,7 +421,10 @@ function detectOpencodeFatalError(output: string): string | undefined {
370421}
371422
372423function hasReadyResponse ( output : string ) : boolean {
373- return / \b r e a d y \b / i. test ( output ) ;
424+ return output
425+ . split ( / \r ? \n / )
426+ . map ( ( line ) => line . trim ( ) . toLowerCase ( ) )
427+ . some ( ( line ) => line === "ready" ) ;
374428}
375429
376430async 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 } ) ;
0 commit comments