@frontman-ai/astro 0.1.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/README.md +53 -0
- package/dist/cli.js +2744 -0
- package/package.json +66 -0
- package/src/FrontmanAstro.res +20 -0
- package/src/FrontmanAstro.res.mjs +36 -0
- package/src/FrontmanAstro__AstroBindings.res +46 -0
- package/src/FrontmanAstro__AstroBindings.res.mjs +2 -0
- package/src/FrontmanAstro__Config.res +85 -0
- package/src/FrontmanAstro__Config.res.mjs +44 -0
- package/src/FrontmanAstro__Integration.res +35 -0
- package/src/FrontmanAstro__Integration.res.mjs +36 -0
- package/src/FrontmanAstro__Middleware.res +149 -0
- package/src/FrontmanAstro__Middleware.res.mjs +141 -0
- package/src/FrontmanAstro__Server.res +196 -0
- package/src/FrontmanAstro__Server.res.mjs +241 -0
- package/src/FrontmanAstro__ToolRegistry.res +21 -0
- package/src/FrontmanAstro__ToolRegistry.res.mjs +41 -0
- package/src/FrontmanAstro__ToolbarApp.res +50 -0
- package/src/FrontmanAstro__ToolbarApp.res.mjs +39 -0
- package/src/cli/FrontmanAstro__Cli.res +126 -0
- package/src/cli/FrontmanAstro__Cli.res.mjs +180 -0
- package/src/cli/FrontmanAstro__Cli__AutoEdit.res +300 -0
- package/src/cli/FrontmanAstro__Cli__AutoEdit.res.mjs +266 -0
- package/src/cli/FrontmanAstro__Cli__Detect.res +298 -0
- package/src/cli/FrontmanAstro__Cli__Detect.res.mjs +345 -0
- package/src/cli/FrontmanAstro__Cli__Files.res +244 -0
- package/src/cli/FrontmanAstro__Cli__Files.res.mjs +321 -0
- package/src/cli/FrontmanAstro__Cli__Install.res +224 -0
- package/src/cli/FrontmanAstro__Cli__Install.res.mjs +194 -0
- package/src/cli/FrontmanAstro__Cli__Style.res +22 -0
- package/src/cli/FrontmanAstro__Cli__Style.res.mjs +61 -0
- package/src/cli/FrontmanAstro__Cli__Templates.res +226 -0
- package/src/cli/FrontmanAstro__Cli__Templates.res.mjs +237 -0
- package/src/cli/cli.mjs +3 -0
- package/src/tools/FrontmanAstro__Tool__GetPages.res +164 -0
- package/src/tools/FrontmanAstro__Tool__GetPages.res.mjs +180 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// CLI entry point for frontman-astro
|
|
2
|
+
// Usage: npx @frontman-ai/astro install --server <host>
|
|
3
|
+
|
|
4
|
+
module Process = FrontmanBindings.Process
|
|
5
|
+
module Install = FrontmanAstro__Cli__Install
|
|
6
|
+
|
|
7
|
+
// Parse command line arguments (simple implementation without external deps)
|
|
8
|
+
type parsedArgs = {
|
|
9
|
+
command: option<string>,
|
|
10
|
+
server: option<string>,
|
|
11
|
+
prefix: option<string>,
|
|
12
|
+
dryRun: bool,
|
|
13
|
+
skipDeps: bool,
|
|
14
|
+
help: bool,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let helpText = `
|
|
18
|
+
Frontman Astro CLI
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
frontman-ai-astro <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
install Install Frontman in an Astro project
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--server <host> Frontman server host (default: api.frontman.sh)
|
|
28
|
+
--prefix <path> Target directory (default: current directory)
|
|
29
|
+
--dry-run Preview changes without writing files
|
|
30
|
+
--skip-deps Skip dependency installation
|
|
31
|
+
--help Show this help message
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
npx @frontman-ai/astro install
|
|
35
|
+
npx @frontman-ai/astro install --server frontman.company.com
|
|
36
|
+
npx @frontman-ai/astro install --dry-run
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
// Simple argument parser
|
|
40
|
+
let parseArgs = (argv: array<string>): parsedArgs => {
|
|
41
|
+
// Skip node and script path (argv[0] and argv[1])
|
|
42
|
+
let args = argv->Array.slice(~start=2, ~end=Array.length(argv))
|
|
43
|
+
|
|
44
|
+
let rec parse = (
|
|
45
|
+
~remaining: array<string>,
|
|
46
|
+
~result: parsedArgs,
|
|
47
|
+
): parsedArgs => {
|
|
48
|
+
switch remaining->Array.get(0) {
|
|
49
|
+
| None => result
|
|
50
|
+
| Some(arg) =>
|
|
51
|
+
let rest = remaining->Array.slice(~start=1, ~end=Array.length(remaining))
|
|
52
|
+
|
|
53
|
+
switch arg {
|
|
54
|
+
| "install" => parse(~remaining=rest, ~result={...result, command: Some("install")})
|
|
55
|
+
| "--server" =>
|
|
56
|
+
let value = rest->Array.get(0)
|
|
57
|
+
let nextRest = rest->Array.slice(~start=1, ~end=Array.length(rest))
|
|
58
|
+
parse(~remaining=nextRest, ~result={...result, server: value})
|
|
59
|
+
| "--prefix" =>
|
|
60
|
+
let value = rest->Array.get(0)
|
|
61
|
+
let nextRest = rest->Array.slice(~start=1, ~end=Array.length(rest))
|
|
62
|
+
parse(~remaining=nextRest, ~result={...result, prefix: value})
|
|
63
|
+
| "--dry-run" => parse(~remaining=rest, ~result={...result, dryRun: true})
|
|
64
|
+
| "--skip-deps" => parse(~remaining=rest, ~result={...result, skipDeps: true})
|
|
65
|
+
| "--help" | "-h" => parse(~remaining=rest, ~result={...result, help: true})
|
|
66
|
+
| _ => parse(~remaining=rest, ~result)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
parse(
|
|
72
|
+
~remaining=args,
|
|
73
|
+
~result={
|
|
74
|
+
command: None,
|
|
75
|
+
server: None,
|
|
76
|
+
prefix: None,
|
|
77
|
+
dryRun: false,
|
|
78
|
+
skipDeps: false,
|
|
79
|
+
help: false,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Main entry point
|
|
85
|
+
let main = async () => {
|
|
86
|
+
let args = parseArgs(Process.argv)
|
|
87
|
+
|
|
88
|
+
switch args.help {
|
|
89
|
+
| true =>
|
|
90
|
+
Console.log(helpText)
|
|
91
|
+
Process.exit(0)
|
|
92
|
+
| false => ()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
switch args.command {
|
|
96
|
+
| Some("install") =>
|
|
97
|
+
let server = switch args.server {
|
|
98
|
+
| Some(s) => s
|
|
99
|
+
| None => "api.frontman.sh"
|
|
100
|
+
}
|
|
101
|
+
let result = await Install.run({
|
|
102
|
+
server,
|
|
103
|
+
prefix: args.prefix,
|
|
104
|
+
dryRun: args.dryRun,
|
|
105
|
+
skipDeps: args.skipDeps,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
switch result {
|
|
109
|
+
| Install.Success => Process.exit(0)
|
|
110
|
+
| Install.PartialSuccess(_) => Process.exit(0) // Still success, just with manual steps
|
|
111
|
+
| Install.Failure(_) => Process.exit(1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
| Some(cmd) =>
|
|
115
|
+
Console.error(`Unknown command: ${cmd}`)
|
|
116
|
+
Console.log(helpText)
|
|
117
|
+
Process.exit(1)
|
|
118
|
+
|
|
119
|
+
| None =>
|
|
120
|
+
Console.log(helpText)
|
|
121
|
+
Process.exit(0)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Run main
|
|
126
|
+
main()->ignore
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Process from "process";
|
|
4
|
+
import * as FrontmanAstro__Cli__Install$FrontmanAiAstro from "./FrontmanAstro__Cli__Install.res.mjs";
|
|
5
|
+
|
|
6
|
+
let helpText = `
|
|
7
|
+
Frontman Astro CLI
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
frontman-ai-astro <command> [options]
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
install Install Frontman in an Astro project
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--server <host> Frontman server host (default: api.frontman.sh)
|
|
17
|
+
--prefix <path> Target directory (default: current directory)
|
|
18
|
+
--dry-run Preview changes without writing files
|
|
19
|
+
--skip-deps Skip dependency installation
|
|
20
|
+
--help Show this help message
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
npx @frontman-ai/astro install
|
|
24
|
+
npx @frontman-ai/astro install --server frontman.company.com
|
|
25
|
+
npx @frontman-ai/astro install --dry-run
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
let args = argv.slice(2, argv.length);
|
|
30
|
+
let _remaining = args;
|
|
31
|
+
let _result = {
|
|
32
|
+
command: undefined,
|
|
33
|
+
server: undefined,
|
|
34
|
+
prefix: undefined,
|
|
35
|
+
dryRun: false,
|
|
36
|
+
skipDeps: false,
|
|
37
|
+
help: false
|
|
38
|
+
};
|
|
39
|
+
while (true) {
|
|
40
|
+
let result = _result;
|
|
41
|
+
let remaining = _remaining;
|
|
42
|
+
let arg = remaining[0];
|
|
43
|
+
if (arg === undefined) {
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
let rest = remaining.slice(1, remaining.length);
|
|
47
|
+
switch (arg) {
|
|
48
|
+
case "--dry-run" :
|
|
49
|
+
_result = {
|
|
50
|
+
command: result.command,
|
|
51
|
+
server: result.server,
|
|
52
|
+
prefix: result.prefix,
|
|
53
|
+
dryRun: true,
|
|
54
|
+
skipDeps: result.skipDeps,
|
|
55
|
+
help: result.help
|
|
56
|
+
};
|
|
57
|
+
_remaining = rest;
|
|
58
|
+
continue;
|
|
59
|
+
case "--prefix" :
|
|
60
|
+
let value = rest[0];
|
|
61
|
+
let nextRest = rest.slice(1, rest.length);
|
|
62
|
+
_result = {
|
|
63
|
+
command: result.command,
|
|
64
|
+
server: result.server,
|
|
65
|
+
prefix: value,
|
|
66
|
+
dryRun: result.dryRun,
|
|
67
|
+
skipDeps: result.skipDeps,
|
|
68
|
+
help: result.help
|
|
69
|
+
};
|
|
70
|
+
_remaining = nextRest;
|
|
71
|
+
continue;
|
|
72
|
+
case "--server" :
|
|
73
|
+
let value$1 = rest[0];
|
|
74
|
+
let nextRest$1 = rest.slice(1, rest.length);
|
|
75
|
+
_result = {
|
|
76
|
+
command: result.command,
|
|
77
|
+
server: value$1,
|
|
78
|
+
prefix: result.prefix,
|
|
79
|
+
dryRun: result.dryRun,
|
|
80
|
+
skipDeps: result.skipDeps,
|
|
81
|
+
help: result.help
|
|
82
|
+
};
|
|
83
|
+
_remaining = nextRest$1;
|
|
84
|
+
continue;
|
|
85
|
+
case "--skip-deps" :
|
|
86
|
+
_result = {
|
|
87
|
+
command: result.command,
|
|
88
|
+
server: result.server,
|
|
89
|
+
prefix: result.prefix,
|
|
90
|
+
dryRun: result.dryRun,
|
|
91
|
+
skipDeps: true,
|
|
92
|
+
help: result.help
|
|
93
|
+
};
|
|
94
|
+
_remaining = rest;
|
|
95
|
+
continue;
|
|
96
|
+
case "--help" :
|
|
97
|
+
case "-h" :
|
|
98
|
+
break;
|
|
99
|
+
case "install" :
|
|
100
|
+
_result = {
|
|
101
|
+
command: "install",
|
|
102
|
+
server: result.server,
|
|
103
|
+
prefix: result.prefix,
|
|
104
|
+
dryRun: result.dryRun,
|
|
105
|
+
skipDeps: result.skipDeps,
|
|
106
|
+
help: result.help
|
|
107
|
+
};
|
|
108
|
+
_remaining = rest;
|
|
109
|
+
continue;
|
|
110
|
+
default:
|
|
111
|
+
_remaining = rest;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
_result = {
|
|
115
|
+
command: result.command,
|
|
116
|
+
server: result.server,
|
|
117
|
+
prefix: result.prefix,
|
|
118
|
+
dryRun: result.dryRun,
|
|
119
|
+
skipDeps: result.skipDeps,
|
|
120
|
+
help: true
|
|
121
|
+
};
|
|
122
|
+
_remaining = rest;
|
|
123
|
+
continue;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function main() {
|
|
128
|
+
let args = parseArgs(process.argv);
|
|
129
|
+
if (args.help) {
|
|
130
|
+
console.log(helpText);
|
|
131
|
+
Process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
let cmd = args.command;
|
|
134
|
+
if (cmd !== undefined) {
|
|
135
|
+
if (cmd === "install") {
|
|
136
|
+
let s = args.server;
|
|
137
|
+
let server = s !== undefined ? s : "api.frontman.sh";
|
|
138
|
+
let result = await FrontmanAstro__Cli__Install$FrontmanAiAstro.run({
|
|
139
|
+
server: server,
|
|
140
|
+
prefix: args.prefix,
|
|
141
|
+
dryRun: args.dryRun,
|
|
142
|
+
skipDeps: args.skipDeps
|
|
143
|
+
});
|
|
144
|
+
if (typeof result !== "object") {
|
|
145
|
+
Process.exit(0);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (result.TAG === "PartialSuccess") {
|
|
149
|
+
Process.exit(0);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
Process.exit(1);
|
|
153
|
+
return;
|
|
154
|
+
} else {
|
|
155
|
+
console.error(`Unknown command: ` + cmd);
|
|
156
|
+
console.log(helpText);
|
|
157
|
+
Process.exit(1);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
console.log(helpText);
|
|
162
|
+
Process.exit(0);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main();
|
|
168
|
+
|
|
169
|
+
let Process$1;
|
|
170
|
+
|
|
171
|
+
let Install;
|
|
172
|
+
|
|
173
|
+
export {
|
|
174
|
+
Process$1 as Process,
|
|
175
|
+
Install,
|
|
176
|
+
helpText,
|
|
177
|
+
parseArgs,
|
|
178
|
+
main,
|
|
179
|
+
}
|
|
180
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// AI-powered auto-edit for existing files during installation
|
|
2
|
+
// Uses OpenCode Zen API (free, no API key required) to merge Frontman into existing files
|
|
3
|
+
|
|
4
|
+
module Bindings = FrontmanBindings
|
|
5
|
+
module Fs = Bindings.Fs
|
|
6
|
+
module Readline = Bindings.Readline
|
|
7
|
+
|
|
8
|
+
module Templates = FrontmanAstro__Cli__Templates
|
|
9
|
+
module Style = FrontmanAstro__Cli__Style
|
|
10
|
+
|
|
11
|
+
type fileType =
|
|
12
|
+
| Config
|
|
13
|
+
| Middleware
|
|
14
|
+
|
|
15
|
+
// OpenCode Zen API configuration
|
|
16
|
+
let apiBaseUrl = "https://opencode.ai/zen/v1/chat/completions"
|
|
17
|
+
let apiKey = "public"
|
|
18
|
+
|
|
19
|
+
// Model fallback chain (all free on OpenCode Zen, no API key needed)
|
|
20
|
+
// Verified against https://opencode.ai/zen/v1/models
|
|
21
|
+
let models = ["gpt-5-nano", "big-pickle", "glm-4.7-free"]
|
|
22
|
+
|
|
23
|
+
// Build the system prompt for the LLM based on file type
|
|
24
|
+
let buildSystemPrompt = (~fileType: fileType, ~host: string): string => {
|
|
25
|
+
let (typeName, manualInstructions, referenceTemplate, rules) = switch fileType {
|
|
26
|
+
| Config => (
|
|
27
|
+
"astro.config.mjs",
|
|
28
|
+
Templates.ErrorMessages.configManualSetup("astro.config.mjs", host),
|
|
29
|
+
Templates.configTemplate(host),
|
|
30
|
+
`- Add the import for '@astrojs/node' at the top of the file
|
|
31
|
+
- Add the import for '@frontman-ai/astro/integration' at the top of the file
|
|
32
|
+
- Add frontmanIntegration() to the integrations array
|
|
33
|
+
- Add SSR dev mode config: ...(isProd ? {} : { output: 'server', adapter: node({ mode: 'standalone' }) })
|
|
34
|
+
- Add const isProd = process.env.NODE_ENV === 'production'; before defineConfig
|
|
35
|
+
- Preserve ALL existing integrations and configuration unchanged
|
|
36
|
+
- Do not remove or modify any existing imports or settings`,
|
|
37
|
+
)
|
|
38
|
+
| Middleware => (
|
|
39
|
+
"src/middleware.ts",
|
|
40
|
+
Templates.ErrorMessages.middlewareManualSetup("src/middleware.ts", host),
|
|
41
|
+
Templates.middlewareTemplate(host),
|
|
42
|
+
`- Add the import for '@frontman-ai/astro' (createMiddleware, makeConfig) at the top of the file
|
|
43
|
+
- Add the import for 'astro:middleware' (defineMiddleware, sequence) at the top of the file
|
|
44
|
+
- Create a Frontman middleware instance with makeConfig({ host: '${host}' })
|
|
45
|
+
- Create a defineMiddleware wrapper for the Frontman handler
|
|
46
|
+
- Use sequence() to combine the Frontman middleware with the existing onRequest handler
|
|
47
|
+
- The Frontman middleware should come FIRST in the sequence
|
|
48
|
+
- Preserve ALL existing middleware functionality unchanged
|
|
49
|
+
- Do not remove or modify any existing imports or middleware logic`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
`You are a code editor. Modify an Astro ${typeName} file to integrate Frontman.
|
|
54
|
+
|
|
55
|
+
## What to add
|
|
56
|
+
${manualInstructions}
|
|
57
|
+
|
|
58
|
+
## Reference template (for a fresh file without any existing code):
|
|
59
|
+
|
|
60
|
+
${referenceTemplate}
|
|
61
|
+
|
|
62
|
+
## Rules
|
|
63
|
+
${rules}
|
|
64
|
+
- Return ONLY the complete file contents. No markdown fences, no explanations, no comments about changes.`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build the user message with the existing file content
|
|
68
|
+
let buildUserMessage = (~existingContent: string): string => {
|
|
69
|
+
`Here is the existing file to modify:
|
|
70
|
+
|
|
71
|
+
${existingContent}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Per-model timeout in milliseconds (30 seconds)
|
|
75
|
+
let requestTimeoutMs = 30_000
|
|
76
|
+
|
|
77
|
+
// Raw JS fetch implementation for Node.js (avoids webapi module dependency)
|
|
78
|
+
let fetchChatCompletion: (
|
|
79
|
+
~url: string,
|
|
80
|
+
~apiKey: string,
|
|
81
|
+
~model: string,
|
|
82
|
+
~systemPrompt: string,
|
|
83
|
+
~userMessage: string,
|
|
84
|
+
~timeoutMs: int,
|
|
85
|
+
) => promise<result<string, string>> = %raw(`
|
|
86
|
+
async function(url, apiKey, model, systemPrompt, userMessage, timeoutMs) {
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(url, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
"Authorization": "Bearer " + apiKey,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
model: model,
|
|
97
|
+
temperature: 0,
|
|
98
|
+
messages: [
|
|
99
|
+
{ role: "system", content: systemPrompt },
|
|
100
|
+
{ role: "user", content: userMessage },
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
return { TAG: "Error", _0: "HTTP " + response.status + ": " + response.statusText };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const json = await response.json();
|
|
110
|
+
const content = json?.choices?.[0]?.message?.content?.trim();
|
|
111
|
+
if (!content) {
|
|
112
|
+
return { TAG: "Error", _0: "Empty response from model" };
|
|
113
|
+
}
|
|
114
|
+
return { TAG: "Ok", _0: content };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err?.name === "TimeoutError") {
|
|
117
|
+
return { TAG: "Error", _0: "Request timed out after " + (timeoutMs / 1000) + "s" };
|
|
118
|
+
}
|
|
119
|
+
return { TAG: "Error", _0: "Request failed: " + (err?.message || "Unknown error") };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
`)
|
|
123
|
+
|
|
124
|
+
// Call a single model
|
|
125
|
+
let callModel = async (
|
|
126
|
+
~model: string,
|
|
127
|
+
~systemPrompt: string,
|
|
128
|
+
~userMessage: string,
|
|
129
|
+
): result<string, string> => {
|
|
130
|
+
await fetchChatCompletion(
|
|
131
|
+
~url=apiBaseUrl,
|
|
132
|
+
~apiKey,
|
|
133
|
+
~model,
|
|
134
|
+
~systemPrompt,
|
|
135
|
+
~userMessage,
|
|
136
|
+
~timeoutMs=requestTimeoutMs,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Strip markdown fences if the LLM wraps the response in them
|
|
141
|
+
let stripMarkdownFences = (content: string): string => {
|
|
142
|
+
let lines = content->String.split("\n")
|
|
143
|
+
let len = lines->Array.length
|
|
144
|
+
|
|
145
|
+
// Check if first line is a markdown fence
|
|
146
|
+
let firstLine = lines->Array.get(0)->Option.getOr("")
|
|
147
|
+
let startsWithFence = firstLine->String.startsWith("```")
|
|
148
|
+
|
|
149
|
+
switch startsWithFence {
|
|
150
|
+
| false => content
|
|
151
|
+
| true =>
|
|
152
|
+
// Find last line that is a closing fence
|
|
153
|
+
let lastLine = lines->Array.get(len - 1)->Option.getOr("")
|
|
154
|
+
let endsWithFence = lastLine->String.trim == "```"
|
|
155
|
+
|
|
156
|
+
let endIdx = switch endsWithFence {
|
|
157
|
+
| true => len - 1
|
|
158
|
+
| false => len
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines
|
|
162
|
+
->Array.slice(~start=1, ~end=endIdx)
|
|
163
|
+
->Array.join("\n")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Validate that the LLM output contains required Frontman imports/config
|
|
168
|
+
let validateOutput = (~content: string, ~fileType: fileType): bool => {
|
|
169
|
+
switch fileType {
|
|
170
|
+
| Config =>
|
|
171
|
+
content->String.includes("frontmanIntegration") &&
|
|
172
|
+
content->String.includes("@frontman-ai/astro") &&
|
|
173
|
+
content->String.includes("defineConfig")
|
|
174
|
+
| Middleware =>
|
|
175
|
+
content->String.includes("@frontman-ai/astro") &&
|
|
176
|
+
content->String.includes("createMiddleware") &&
|
|
177
|
+
content->String.includes("makeConfig") &&
|
|
178
|
+
content->String.includes("onRequest")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Call LLM with model fallback chain
|
|
183
|
+
let callLLM = async (
|
|
184
|
+
~existingContent: string,
|
|
185
|
+
~fileType: fileType,
|
|
186
|
+
~host: string,
|
|
187
|
+
): result<string, string> => {
|
|
188
|
+
let systemPrompt = buildSystemPrompt(~fileType, ~host)
|
|
189
|
+
let userMessage = buildUserMessage(~existingContent)
|
|
190
|
+
|
|
191
|
+
let rec tryModels = async (remaining: array<string>, errors: array<string>) => {
|
|
192
|
+
switch remaining->Array.get(0) {
|
|
193
|
+
| None =>
|
|
194
|
+
let allErrors = errors->Array.join("; ")
|
|
195
|
+
Error(`All models failed: ${allErrors}`)
|
|
196
|
+
| Some(model) =>
|
|
197
|
+
let rest = remaining->Array.slice(~start=1, ~end=Array.length(remaining))
|
|
198
|
+
Console.log(` ${Style.dim(`Trying model: ${model}...`)}`)
|
|
199
|
+
|
|
200
|
+
switch await callModel(~model, ~systemPrompt, ~userMessage) {
|
|
201
|
+
| Ok(rawContent) =>
|
|
202
|
+
let content = stripMarkdownFences(rawContent)
|
|
203
|
+
switch validateOutput(~content, ~fileType) {
|
|
204
|
+
| true => Ok(content)
|
|
205
|
+
| false =>
|
|
206
|
+
let err = `${model}: output validation failed (missing Frontman imports)`
|
|
207
|
+
Console.log(` ${Style.dim(err)}`)
|
|
208
|
+
await tryModels(rest, errors->Array.concat([err]))
|
|
209
|
+
}
|
|
210
|
+
| Error(err) =>
|
|
211
|
+
let errMsg = `${model}: ${err}`
|
|
212
|
+
Console.log(` ${Style.dim(errMsg)}`)
|
|
213
|
+
await tryModels(rest, errors->Array.concat([errMsg]))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await tryModels(models, [])
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Prompt user for auto-edit with privacy disclosure (batched for multiple files)
|
|
222
|
+
let promptUserForAutoEdit = async (~fileNames: array<string>): bool => {
|
|
223
|
+
// Skip prompt if not interactive (piped input)
|
|
224
|
+
switch Readline.isTTY() {
|
|
225
|
+
| false => false
|
|
226
|
+
| true =>
|
|
227
|
+
Console.log("")
|
|
228
|
+
switch fileNames->Array.length {
|
|
229
|
+
| 1 =>
|
|
230
|
+
let fileName = fileNames->Array.getUnsafe(0)
|
|
231
|
+
Console.log(
|
|
232
|
+
` ${Style.warn} ${Style.bold(fileName)} exists but doesn't have Frontman configured.`,
|
|
233
|
+
)
|
|
234
|
+
| _ =>
|
|
235
|
+
Console.log(
|
|
236
|
+
` ${Style.warn} The following files exist but don't have Frontman configured:`,
|
|
237
|
+
)
|
|
238
|
+
fileNames->Array.forEach(fileName => {
|
|
239
|
+
Console.log(` ${Style.purple("•")} ${Style.bold(fileName)}`)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
Console.log(
|
|
243
|
+
` ${Style.dim("Your file contents will be sent to a public LLM (OpenCode Zen).")}`,
|
|
244
|
+
)
|
|
245
|
+
Console.log("")
|
|
246
|
+
|
|
247
|
+
let answer = await Readline.question(` Auto-edit using AI? ${Style.dim("[Y/n]")} `)
|
|
248
|
+
|
|
249
|
+
// Ctrl+D (EOF) returns null — treat as decline (never auto-consent)
|
|
250
|
+
switch answer->Nullable.toOption {
|
|
251
|
+
| None => false
|
|
252
|
+
| Some(raw) =>
|
|
253
|
+
switch raw->String.trim->String.toLowerCase {
|
|
254
|
+
| "" | "y" | "yes" => true
|
|
255
|
+
| _ => false
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Result type re-exported for use in Files.res
|
|
262
|
+
type autoEditResult =
|
|
263
|
+
| AutoEdited(string)
|
|
264
|
+
| AutoEditFailed(string)
|
|
265
|
+
|
|
266
|
+
// Maximum file size (in bytes) to send to the LLM. Files larger than this
|
|
267
|
+
// are skipped to avoid excessive latency or request failures.
|
|
268
|
+
let maxFileSizeBytes = 50_000
|
|
269
|
+
|
|
270
|
+
// Main auto-edit function: call LLM, write file
|
|
271
|
+
let autoEditFile = async (
|
|
272
|
+
~filePath: string,
|
|
273
|
+
~fileName: string,
|
|
274
|
+
~existingContent: string,
|
|
275
|
+
~fileType: fileType,
|
|
276
|
+
~host: string,
|
|
277
|
+
): autoEditResult => {
|
|
278
|
+
// Guard: skip files that are too large for reliable LLM editing
|
|
279
|
+
let fileSize = existingContent->String.length
|
|
280
|
+
switch fileSize > maxFileSizeBytes {
|
|
281
|
+
| true =>
|
|
282
|
+
AutoEditFailed(
|
|
283
|
+
`${fileName} is too large (${(fileSize / 1000)->Int.toString}KB) for auto-edit — max ${(maxFileSizeBytes / 1000)->Int.toString}KB`,
|
|
284
|
+
)
|
|
285
|
+
| false =>
|
|
286
|
+
Console.log("")
|
|
287
|
+
Console.log(` ${Style.purple("⟳")} Merging Frontman into ${Style.bold(fileName)}...`)
|
|
288
|
+
|
|
289
|
+
switch await callLLM(~existingContent, ~fileType, ~host) {
|
|
290
|
+
| Ok(newContent) =>
|
|
291
|
+
try {
|
|
292
|
+
await Fs.Promises.writeFile(filePath, newContent)
|
|
293
|
+
AutoEdited(fileName)
|
|
294
|
+
} catch {
|
|
295
|
+
| _ => AutoEditFailed(`Failed to write ${fileName}`)
|
|
296
|
+
}
|
|
297
|
+
| Error(err) => AutoEditFailed(err)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|