loopgen 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.
@@ -0,0 +1,41 @@
1
+ export function generateCursorFiles(scan, loops) {
2
+ return loops.map((loop) => ({
3
+ path: `.cursor/rules/loopgen-${loop.id}.mdc`,
4
+ content: cursorRule(scan, loop)
5
+ }));
6
+ }
7
+ function cursorRule(scan, loop) {
8
+ return `---
9
+ description: ${loop.goal}
10
+ globs:
11
+ alwaysApply: false
12
+ ---
13
+
14
+ # ${loop.title}
15
+
16
+ Bounded loop-engineering rule for ${scan.projectName}, generated by loopgen. Apply this rule when working
17
+ on \`${loop.id}\`.
18
+
19
+ ## Goal
20
+
21
+ ${loop.goal}
22
+
23
+ ## Steps
24
+
25
+ ${loop.actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}
26
+
27
+ ## Verify before declaring success
28
+
29
+ ${loop.verification.commands.map((command) => `- \`${command}\``).join("\n")}
30
+
31
+ Acceptance criteria: ${loop.verification.acceptanceCriteria}
32
+
33
+ ## Guardrails
34
+
35
+ - Maximum iterations: ${loop.stopCriteria.maxIterations}.
36
+ - Keep maker and checker work separate.
37
+ - Do not read or modify: ${loop.permissions.forbiddenPaths.map((item) => `\`${item}\``).join(", ")}.
38
+ - Log every attempt to \`${loop.stateFile}\`.
39
+ - Stop and ask for human input when: ${loop.stopCriteria.requireHumanInputOn.join("; ")}.
40
+ ${loop.verification.requiresHumanCommandDefinition ? "\nThis rule is a draft until the TODO verification command is replaced.\n" : ""}`;
41
+ }
@@ -0,0 +1,172 @@
1
+ export function generateLocalModelFiles(scan, loops, options) {
2
+ const config = normalizeLocalConfig(options.adapterId, options.config);
3
+ return [
4
+ {
5
+ path: `.loopgen/adapters/${options.adapterId}/config.json`,
6
+ content: JSON.stringify(renderConfig(scan.projectName, options.adapterId, config), null, 2) + "\n"
7
+ },
8
+ ...loops.map((loop) => ({
9
+ path: `.loopgen/adapters/${options.adapterId}/${loop.id}.md`,
10
+ content: renderRunbook(scan, loop, options.adapterId, config)
11
+ }))
12
+ ];
13
+ }
14
+ export function localModelWarnings(adapterId, config) {
15
+ if (adapterId !== "ollama" && adapterId !== "openai-compatible")
16
+ return [];
17
+ const warnings = [];
18
+ if (!config?.model?.trim() || config.model === "TODO_MODEL") {
19
+ warnings.push(`${adapterId} needs a model name before the generated runbook can call the local runtime.`);
20
+ }
21
+ if (!config?.baseUrl?.trim()) {
22
+ warnings.push(`${adapterId} needs a base URL before the generated runbook can call the local runtime.`);
23
+ }
24
+ return warnings;
25
+ }
26
+ function normalizeLocalConfig(adapterId, config) {
27
+ if (adapterId === "ollama") {
28
+ return {
29
+ preset: config.preset ?? "ollama",
30
+ baseUrl: config.baseUrl?.trim() || "http://localhost:11434",
31
+ model: config.model?.trim() || "TODO_MODEL"
32
+ };
33
+ }
34
+ return {
35
+ preset: config.preset ?? "lm-studio",
36
+ baseUrl: config.baseUrl?.trim() || "http://localhost:1234/v1",
37
+ model: config.model?.trim() || "TODO_MODEL",
38
+ apiKeyEnv: config.apiKeyEnv?.trim() ?? ""
39
+ };
40
+ }
41
+ function renderConfig(projectName, adapterId, config) {
42
+ return {
43
+ version: "0.1",
44
+ project: projectName,
45
+ adapter: adapterId,
46
+ preset: config.preset,
47
+ baseUrl: config.baseUrl,
48
+ model: config.model,
49
+ apiKeyEnv: adapterId === "openai-compatible" ? config.apiKeyEnv || undefined : undefined,
50
+ executesAutomatically: false,
51
+ warnings: localModelWarnings(adapterId, config),
52
+ notes: [
53
+ "loopgen generates local model runbooks only; it does not execute the model.",
54
+ "Do not write API keys or secrets into this file."
55
+ ]
56
+ };
57
+ }
58
+ function renderRunbook(scan, loop, adapterId, config) {
59
+ return `# ${loop.title} local model runbook
60
+
61
+ Project: ${scan.projectName}
62
+ Loop id: ${loop.id}
63
+ Adapter: ${adapterId}
64
+ Preset: ${config.preset ?? "custom"}
65
+ Base URL: ${config.baseUrl}
66
+ Model: ${config.model}
67
+
68
+ > loopgen generated this runbook for a local/open-source model runtime. It does not execute the model automatically.
69
+
70
+ ## Goal
71
+
72
+ ${loop.goal}
73
+
74
+ Expected outcome: ${loop.expectedOutcome}
75
+
76
+ ## Context to provide
77
+
78
+ ${loop.contextSources.map((source) => `- ${source}`).join("\n")}
79
+
80
+ ## Prompt template
81
+
82
+ \`\`\`text
83
+ You are running a bounded loop engineering workflow for ${scan.projectName}.
84
+
85
+ Goal:
86
+ ${loop.goal}
87
+
88
+ Expected outcome:
89
+ ${loop.expectedOutcome}
90
+
91
+ Context sources to inspect or summarize:
92
+ ${loop.contextSources.map((source) => `- ${source}`).join("\n")}
93
+
94
+ Loop steps:
95
+ ${loop.actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}
96
+
97
+ Verification commands:
98
+ ${loop.verification.commands.map((command) => `- ${command}`).join("\n")}
99
+
100
+ State file:
101
+ ${loop.stateFile}
102
+
103
+ Stop and ask for human input when:
104
+ ${loop.stopCriteria.requireHumanInputOn.map((condition) => `- ${condition}`).join("\n")}
105
+
106
+ Return a concise plan for the next maker iteration, the files likely involved, verification to run, and state-file notes to append.
107
+ \`\`\`
108
+
109
+ ## Curl example
110
+
111
+ ${adapterId === "ollama" ? ollamaCurlExample(config, loop) : openAiCompatibleCurlExample(config, loop)}
112
+
113
+ ## Verification
114
+
115
+ Run these commands before declaring success:
116
+
117
+ ${loop.verification.commands.map((command) => `- \`${command}\``).join("\n")}
118
+
119
+ Acceptance criteria: ${loop.verification.acceptanceCriteria}
120
+
121
+ ## Safety
122
+
123
+ - State file: \`${loop.stateFile}\`
124
+ - Maximum iterations: ${loop.stopCriteria.maxIterations}
125
+ - Timeout minutes: ${loop.stopCriteria.timeoutMinutes}
126
+ - Network allowed: ${loop.permissions.allowNetwork ? "yes" : "no"}
127
+ - PR creation allowed: ${loop.permissions.allowPrCreation ? "yes" : "no"}
128
+ - Do not read or modify: ${loop.permissions.forbiddenPaths.map((item) => `\`${item}\``).join(", ")}
129
+
130
+ ${localModelWarnings(adapterId, config).length ? `## TODO\n\n${localModelWarnings(adapterId, config).map((warning) => `- ${warning}`).join("\n")}\n` : ""}
131
+ `;
132
+ }
133
+ function ollamaCurlExample(config, loop) {
134
+ return `\`\`\`bash
135
+ curl ${config.baseUrl}/api/chat \\
136
+ -H "Content-Type: application/json" \\
137
+ -d '{
138
+ "model": "${config.model}",
139
+ "stream": false,
140
+ "messages": [
141
+ {
142
+ "role": "system",
143
+ "content": "You are a careful loop engineering assistant. Keep changes bounded, verify before success, and update the loop state file."
144
+ },
145
+ {
146
+ "role": "user",
147
+ "content": "Run the next maker iteration for loop ${loop.id}. Use the prompt template in this runbook."
148
+ }
149
+ ]
150
+ }'
151
+ \`\`\``;
152
+ }
153
+ function openAiCompatibleCurlExample(config, loop) {
154
+ const authLine = config.apiKeyEnv ? ` -H "Authorization: Bearer $${config.apiKeyEnv}" \\\n` : "";
155
+ return `\`\`\`bash
156
+ curl ${config.baseUrl}/chat/completions \\
157
+ -H "Content-Type: application/json" \\
158
+ ${authLine} -d '{
159
+ "model": "${config.model}",
160
+ "messages": [
161
+ {
162
+ "role": "system",
163
+ "content": "You are a careful loop engineering assistant. Keep changes bounded, verify before success, and update the loop state file."
164
+ },
165
+ {
166
+ "role": "user",
167
+ "content": "Run the next maker iteration for loop ${loop.id}. Use the prompt template in this runbook."
168
+ }
169
+ ]
170
+ }'
171
+ \`\`\``;
172
+ }
@@ -0,0 +1,41 @@
1
+ export function generateWindsurfFiles(scan, loops) {
2
+ return [
3
+ {
4
+ path: ".windsurfrules",
5
+ content: renderWindsurfRules(scan, loops)
6
+ }
7
+ ];
8
+ }
9
+ function renderWindsurfRules(scan, loops) {
10
+ const loopSections = loops.map((loop) => renderLoopSection(loop)).join("\n\n");
11
+ return `# loopgen rules for ${scan.projectName}
12
+
13
+ Generated by loopgen. These are bounded, verifiable loops with safety rails for Windsurf/Cascade. Keep
14
+ changes small, run verification, and stop at the iteration limit.
15
+
16
+ ## Global guardrails
17
+
18
+ - Make the smallest change that satisfies the goal.
19
+ - Run verification commands before declaring success.
20
+ - Keep maker and checker work separate.
21
+ - Never read or modify secret or credential files.
22
+ - Log every attempt to the loop's state file and stop when a stop condition is met.
23
+
24
+ ${loopSections}
25
+ `;
26
+ }
27
+ function renderLoopSection(loop) {
28
+ return `## ${loop.title} (${loop.id})
29
+
30
+ ${loop.goal}
31
+
32
+ Steps:
33
+ ${loop.actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}
34
+
35
+ Verify:
36
+ ${loop.verification.commands.map((command) => `- \`${command}\``).join("\n")}
37
+
38
+ Guardrails: max ${loop.stopCriteria.maxIterations} iterations; state file \`${loop.stateFile}\`; do not touch ${loop.permissions.forbiddenPaths
39
+ .map((item) => `\`${item}\``)
40
+ .join(", ")}.${loop.verification.requiresHumanCommandDefinition ? " Draft until a real verification command is set." : ""}`;
41
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import readline from "node:readline/promises";
7
+ import { Command } from "commander";
8
+ import { applyGeneratedFiles } from "./core/fs-plan.js";
9
+ import { demoProjectRoot, generateLoopProject } from "./core/generator.js";
10
+ import { scanProject } from "./core/scanner.js";
11
+ import { TEMPLATE_DEFINITIONS } from "./core/templates.js";
12
+ import { startLoopgenServer } from "./server.js";
13
+ import { DEFAULT_ADAPTER_IDS, parseAdapterIds } from "./core/adapters.js";
14
+ const PROJECT_MANIFESTS = [
15
+ "package.json",
16
+ "pyproject.toml",
17
+ "requirements.txt",
18
+ "go.mod",
19
+ "Cargo.toml",
20
+ "pom.xml",
21
+ "build.gradle",
22
+ "Gemfile",
23
+ "composer.json"
24
+ ];
25
+ function looksLikeProject(dir) {
26
+ return PROJECT_MANIFESTS.some((file) => existsSync(path.join(dir, file)));
27
+ }
28
+ const program = new Command();
29
+ program
30
+ .name("loopgen")
31
+ .description("Generate bounded, verifiable AI agent configs for Codex, Claude, Cursor, and local models — with safety rails baked in.")
32
+ .version("0.1.0");
33
+ program
34
+ .command("init")
35
+ .argument("[project]", "project directory", ".")
36
+ .option("-p, --port <port>", "port for the local wizard", "8787")
37
+ .option("--host <host>", "host for the local wizard", "127.0.0.1")
38
+ .option("--no-open", "do not open the browser automatically")
39
+ .description("Start the local Web wizard.")
40
+ .action(async (project, options) => {
41
+ const projectRoot = path.resolve(project);
42
+ const isProject = looksLikeProject(projectRoot);
43
+ const { url } = await startLoopgenServer({
44
+ projectRoot,
45
+ host: options.host,
46
+ port: Number(options.port)
47
+ });
48
+ const openUrl = isProject ? `${url}/?project=${encodeURIComponent(projectRoot)}` : url;
49
+ console.log(`loopgen wizard running at ${url}`);
50
+ if (isProject) {
51
+ console.log(`Project: ${projectRoot}`);
52
+ }
53
+ else {
54
+ console.log("No project manifest detected here — opening the built-in demo so you can explore safely.");
55
+ console.log("Run `loopgen init <path-to-your-project>` to scan a real project.");
56
+ }
57
+ if (options.open) {
58
+ openBrowser(openUrl);
59
+ }
60
+ });
61
+ program
62
+ .command("scan")
63
+ .argument("[project]", "project directory", ".")
64
+ .option("--json", "print the full scan as JSON")
65
+ .option("--demo", "scan the built-in demo project")
66
+ .description("Scan a project and infer loop inputs.")
67
+ .action(async (project, options) => {
68
+ const scan = await scanProject(options.demo ? demoProjectRoot() : path.resolve(project));
69
+ if (options.json) {
70
+ console.log(JSON.stringify(scan, null, 2));
71
+ return;
72
+ }
73
+ console.log(`${scan.projectName} (${scan.primaryLanguage})`);
74
+ console.log(`Root: ${scan.root}`);
75
+ console.log(`Package managers: ${scan.packageManagers.join(", ") || "none detected"}`);
76
+ console.log(`Commands: ${formatCommands(scan.commands)}`);
77
+ console.log(`CI: ${scan.ci.workflowFiles.join(", ") || "none detected"}`);
78
+ for (const warning of scan.warnings) {
79
+ console.warn(`Warning: ${warning}`);
80
+ }
81
+ });
82
+ program
83
+ .command("create")
84
+ .argument("[template]", "template id or 'all'", "all")
85
+ .argument("[project]", "project directory", ".")
86
+ .option("--adapters <items>", "comma-separated adapters", DEFAULT_ADAPTER_IDS.join(","))
87
+ .option("--ollama-model <model>", "model name for the Ollama adapter")
88
+ .option("--ollama-base-url <url>", "base URL for the Ollama adapter")
89
+ .option("--openai-compatible-model <model>", "model name for the OpenAI-compatible adapter")
90
+ .option("--openai-compatible-base-url <url>", "base URL for the OpenAI-compatible adapter")
91
+ .option("--openai-compatible-api-key-env <name>", "environment variable name for an OpenAI-compatible API key")
92
+ .option("--json", "print generated file metadata as JSON")
93
+ .option("--demo", "use the built-in demo project")
94
+ .description("Create loop configuration in memory and print a summary.")
95
+ .action(async (template, project, options) => {
96
+ const result = await generateLoopProject(buildGenerationOptions(project, template, options.adapters, options.demo ? "demo" : "project", buildAdapterConfigs(options)));
97
+ if (options.json) {
98
+ console.log(JSON.stringify(result, null, 2));
99
+ return;
100
+ }
101
+ printGenerationSummary(result);
102
+ });
103
+ program
104
+ .command("preview")
105
+ .argument("[project]", "project directory", ".")
106
+ .option("--templates <items>", "comma-separated loop templates", "all")
107
+ .option("--adapters <items>", "comma-separated adapters", DEFAULT_ADAPTER_IDS.join(","))
108
+ .option("--ollama-model <model>", "model name for the Ollama adapter")
109
+ .option("--ollama-base-url <url>", "base URL for the Ollama adapter")
110
+ .option("--openai-compatible-model <model>", "model name for the OpenAI-compatible adapter")
111
+ .option("--openai-compatible-base-url <url>", "base URL for the OpenAI-compatible adapter")
112
+ .option("--openai-compatible-api-key-env <name>", "environment variable name for an OpenAI-compatible API key")
113
+ .option("--demo", "use the built-in demo project")
114
+ .description("Preview the files loopgen would write.")
115
+ .action(async (project, options) => {
116
+ const result = await generateLoopProject(buildGenerationOptions(project, options.templates, options.adapters, options.demo ? "demo" : "project", buildAdapterConfigs(options)));
117
+ printGenerationSummary(result);
118
+ console.log("\nDiff preview:\n");
119
+ console.log(result.diff || "No changes.");
120
+ });
121
+ program
122
+ .command("apply")
123
+ .argument("[project]", "project directory", ".")
124
+ .option("--templates <items>", "comma-separated loop templates", "all")
125
+ .option("--adapters <items>", "comma-separated adapters", DEFAULT_ADAPTER_IDS.join(","))
126
+ .option("--ollama-model <model>", "model name for the Ollama adapter")
127
+ .option("--ollama-base-url <url>", "base URL for the Ollama adapter")
128
+ .option("--openai-compatible-model <model>", "model name for the OpenAI-compatible adapter")
129
+ .option("--openai-compatible-base-url <url>", "base URL for the OpenAI-compatible adapter")
130
+ .option("--openai-compatible-api-key-env <name>", "environment variable name for an OpenAI-compatible API key")
131
+ .option("-y, --yes", "apply without an interactive confirmation")
132
+ .description("Write generated loop files after confirmation.")
133
+ .action(async (project, options) => {
134
+ const result = await generateLoopProject(buildGenerationOptions(project, options.templates, options.adapters, "project", buildAdapterConfigs(options)));
135
+ printGenerationSummary(result);
136
+ console.log("\nDiff preview:\n");
137
+ console.log(result.diff || "No changes.");
138
+ if (!options.yes && !(await confirm("Apply these files?"))) {
139
+ console.log("Canceled.");
140
+ return;
141
+ }
142
+ const written = await applyGeneratedFiles(result.scan.root, result.files);
143
+ console.log(`Wrote ${written.length} files.`);
144
+ });
145
+ program.parseAsync(process.argv).catch((error) => {
146
+ console.error(error instanceof Error ? error.message : String(error));
147
+ process.exitCode = 1;
148
+ });
149
+ function buildGenerationOptions(project, templates, adapters, experienceMode = "project", adapterConfigs) {
150
+ return {
151
+ projectRoot: path.resolve(project),
152
+ experienceMode,
153
+ selectedTemplates: parseTemplates(templates),
154
+ adapters: parseAdapters(adapters),
155
+ adapterConfigs
156
+ };
157
+ }
158
+ function parseTemplates(value) {
159
+ if (value === "all")
160
+ return undefined;
161
+ const ids = value.split(",").map((item) => item.trim()).filter(Boolean);
162
+ const valid = new Set(TEMPLATE_DEFINITIONS.map((template) => template.id));
163
+ for (const id of ids) {
164
+ if (!valid.has(id)) {
165
+ throw new Error(`Unknown template: ${id}`);
166
+ }
167
+ }
168
+ return ids;
169
+ }
170
+ function parseAdapters(value) {
171
+ return parseAdapterIds(value);
172
+ }
173
+ function buildAdapterConfigs(options) {
174
+ return {
175
+ ollama: {
176
+ preset: "ollama",
177
+ model: options.ollamaModel,
178
+ baseUrl: options.ollamaBaseUrl
179
+ },
180
+ "openai-compatible": {
181
+ preset: options.openaiCompatibleBaseUrl ? "custom-openai-compatible" : "lm-studio",
182
+ model: options.openaiCompatibleModel,
183
+ baseUrl: options.openaiCompatibleBaseUrl,
184
+ apiKeyEnv: options.openaiCompatibleApiKeyEnv
185
+ }
186
+ };
187
+ }
188
+ function printGenerationSummary(result) {
189
+ console.log(`Project: ${result.scan.projectName}`);
190
+ console.log(`Loops: ${result.loops.map((loop) => loop.id).join(", ")}`);
191
+ console.log(`Files: ${result.files.length}`);
192
+ for (const warning of result.warnings) {
193
+ console.warn(`Warning: ${warning}`);
194
+ }
195
+ }
196
+ function formatCommands(commands) {
197
+ const entries = Object.entries(commands).filter(([, command]) => command);
198
+ return entries.length ? entries.map(([name, command]) => `${name}=${command}`).join(", ") : "none inferred";
199
+ }
200
+ async function confirm(question) {
201
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
202
+ const answer = await rl.question(`${question} [y/N] `);
203
+ rl.close();
204
+ return /^y(es)?$/i.test(answer.trim());
205
+ }
206
+ function openBrowser(url) {
207
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
208
+ const args = process.platform === "win32" ? ["/c", "start", url] : [url];
209
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
210
+ child.unref();
211
+ }
@@ -0,0 +1,162 @@
1
+ export const DEFAULT_ADAPTER_IDS = ["codex", "claude"];
2
+ export const ADAPTER_DEFINITIONS = [
3
+ {
4
+ id: "agents-md",
5
+ name: "AGENTS.md",
6
+ vendor: "Cross-tool standard",
7
+ description: "One AGENTS.md read by Claude Code, Codex, Cursor, Copilot, Gemini CLI, Aider, and more",
8
+ outputPath: "AGENTS.md",
9
+ files: ["AGENTS.md"],
10
+ capabilities: ["Universal agent instructions", "Per-loop sections", "Safety rules"],
11
+ prBehavior: "Describes loop behavior; does not create PRs by itself.",
12
+ safetyNotes: ["Portable across most AI coding agents.", "Forbidden paths are listed per loop."],
13
+ configurable: false
14
+ },
15
+ {
16
+ id: "cursor",
17
+ name: "Cursor",
18
+ vendor: "Cursor",
19
+ description: "Project rules as .cursor/rules/*.mdc files",
20
+ outputPath: ".cursor/rules/",
21
+ files: ["loopgen-*.mdc"],
22
+ capabilities: ["Cursor project rule", "MDC frontmatter", "Per-loop guardrails"],
23
+ prBehavior: "Guides Cursor agents; does not create PRs by itself.",
24
+ safetyNotes: ["Each rule lists forbidden paths and iteration limits."],
25
+ configurable: false
26
+ },
27
+ {
28
+ id: "windsurf",
29
+ name: "Windsurf",
30
+ vendor: "Codeium",
31
+ description: "A .windsurfrules file for Windsurf/Cascade",
32
+ outputPath: ".windsurfrules",
33
+ files: [".windsurfrules"],
34
+ capabilities: ["Windsurf rules", "Global guardrails", "Per-loop sections"],
35
+ prBehavior: "Guides Windsurf agents; does not create PRs by itself.",
36
+ safetyNotes: ["Includes global and per-loop safety guardrails."],
37
+ configurable: false
38
+ },
39
+ {
40
+ id: "codex",
41
+ name: "Codex",
42
+ vendor: "OpenAI",
43
+ description: "Skills, automation prompts, checker TOML",
44
+ outputPath: ".codex/",
45
+ files: ["skills/*/SKILL.md", "automations/*.md", "agents/*-checker.toml"],
46
+ capabilities: ["Automation prompt", "Project skill", "Checker agent"],
47
+ prBehavior: "Can prepare a PR only when the loop allows PR creation.",
48
+ safetyNotes: ["Dry-run through preview before files are applied.", "Forbidden paths are listed in each generated loop."],
49
+ configurable: false
50
+ },
51
+ {
52
+ id: "claude",
53
+ name: "Claude",
54
+ vendor: "Anthropic",
55
+ description: "Skills, loop guides, checker notes",
56
+ outputPath: ".claude/",
57
+ files: ["skills/*/SKILL.md", "loops/*.md", "agents/*-checker.md"],
58
+ capabilities: ["Claude skill", "Loop guide", "Checker notes"],
59
+ prBehavior: "Records PR handling guidance; it does not create PRs by default.",
60
+ safetyNotes: ["Requires the user's local Claude Code setup.", "State files record attempts and blockers."],
61
+ configurable: false
62
+ },
63
+ {
64
+ id: "ollama",
65
+ name: "Ollama",
66
+ vendor: "Local runtime",
67
+ description: "Local model config and native Ollama chat runbooks",
68
+ outputPath: ".loopgen/adapters/ollama/",
69
+ files: ["config.json", "*.md"],
70
+ capabilities: ["Local model runbook", "Native /api/chat curl", "Prompt template"],
71
+ prBehavior: "Does not create PRs; generated runbooks guide local model usage.",
72
+ safetyNotes: ["Uses a local endpoint by default.", "No API keys are written into generated files.", "loopgen does not execute the model automatically."],
73
+ configurable: true
74
+ },
75
+ {
76
+ id: "openai-compatible",
77
+ name: "OpenAI-compatible",
78
+ vendor: "Local or self-hosted runtime",
79
+ description: "Runbooks for LM Studio, llama.cpp, vLLM, LocalAI, and similar servers",
80
+ outputPath: ".loopgen/adapters/openai-compatible/",
81
+ files: ["config.json", "*.md"],
82
+ capabilities: ["OpenAI-style chat completions", "Preset base URLs", "Prompt template"],
83
+ prBehavior: "Does not create PRs; generated runbooks guide local model usage.",
84
+ safetyNotes: ["References API keys by environment variable name only.", "Works with local or private compatible servers.", "loopgen does not execute the model automatically."],
85
+ configurable: true
86
+ }
87
+ ];
88
+ export const ADAPTER_IDS = ADAPTER_DEFINITIONS.map((adapter) => adapter.id);
89
+ export const ADAPTER_PRESETS = {
90
+ ollama: {
91
+ adapterId: "ollama",
92
+ label: "Ollama",
93
+ baseUrl: "http://localhost:11434",
94
+ description: "Native Ollama API server."
95
+ },
96
+ "lm-studio": {
97
+ adapterId: "openai-compatible",
98
+ label: "LM Studio",
99
+ baseUrl: "http://localhost:1234/v1",
100
+ description: "LM Studio local server with OpenAI-compatible endpoints."
101
+ },
102
+ "llama-cpp": {
103
+ adapterId: "openai-compatible",
104
+ label: "llama.cpp",
105
+ baseUrl: "http://localhost:8080/v1",
106
+ description: "llama.cpp server with OpenAI-compatible endpoints."
107
+ },
108
+ "custom-openai-compatible": {
109
+ adapterId: "openai-compatible",
110
+ label: "Custom",
111
+ baseUrl: "http://localhost:8000/v1",
112
+ description: "Custom OpenAI-compatible chat completions server."
113
+ }
114
+ };
115
+ export function isAdapterId(value) {
116
+ return ADAPTER_IDS.includes(value);
117
+ }
118
+ export function parseAdapterIds(value) {
119
+ const ids = value.split(",").map((item) => item.trim()).filter(Boolean);
120
+ for (const id of ids) {
121
+ if (!isAdapterId(id)) {
122
+ throw new Error(`Unknown adapter: ${id}`);
123
+ }
124
+ }
125
+ return ids.length ? ids : DEFAULT_ADAPTER_IDS;
126
+ }
127
+ export function adapterDefinitionFor(id) {
128
+ return ADAPTER_DEFINITIONS.find((adapter) => adapter.id === id);
129
+ }
130
+ export function defaultAdapterConfig(id) {
131
+ if (id === "ollama") {
132
+ return {
133
+ preset: "ollama",
134
+ baseUrl: ADAPTER_PRESETS.ollama.baseUrl,
135
+ model: ""
136
+ };
137
+ }
138
+ if (id === "openai-compatible") {
139
+ return {
140
+ preset: "lm-studio",
141
+ baseUrl: ADAPTER_PRESETS["lm-studio"].baseUrl,
142
+ model: "",
143
+ apiKeyEnv: ""
144
+ };
145
+ }
146
+ return {};
147
+ }
148
+ export function normalizeAdapterConfigs(configs) {
149
+ const normalized = {};
150
+ for (const adapter of ADAPTER_DEFINITIONS) {
151
+ const defaults = defaultAdapterConfig(adapter.id);
152
+ const provided = configs?.[adapter.id] ?? {};
153
+ normalized[adapter.id] = {
154
+ ...defaults,
155
+ ...definedAdapterConfig(provided)
156
+ };
157
+ }
158
+ return normalized;
159
+ }
160
+ function definedAdapterConfig(config) {
161
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
162
+ }
@@ -0,0 +1,29 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ export async function createPreviewDiff(projectRoot, files) {
4
+ const chunks = [];
5
+ for (const file of files) {
6
+ const absolute = path.join(projectRoot, file.path);
7
+ const existing = await fs.readFile(absolute, "utf8").catch(() => undefined);
8
+ if (existing === file.content) {
9
+ continue;
10
+ }
11
+ chunks.push(renderFileDiff(file.path, existing, file.content));
12
+ }
13
+ return chunks.join("\n");
14
+ }
15
+ export function renderFileDiff(filePath, before, after) {
16
+ const beforeLines = before?.split("\n") ?? [];
17
+ const afterLines = after.split("\n");
18
+ const oldLabel = before === undefined ? "/dev/null" : `a/${filePath}`;
19
+ const lines = [`--- ${oldLabel}`, `+++ b/${filePath}`];
20
+ if (before === undefined) {
21
+ lines.push(`@@ -0,0 +1,${afterLines.length} @@`);
22
+ lines.push(...afterLines.map((line) => `+${line}`));
23
+ return lines.join("\n");
24
+ }
25
+ lines.push(`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`);
26
+ lines.push(...beforeLines.map((line) => `-${line}`));
27
+ lines.push(...afterLines.map((line) => `+${line}`));
28
+ return lines.join("\n");
29
+ }
@@ -0,0 +1,12 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ export async function applyGeneratedFiles(projectRoot, files) {
4
+ const written = [];
5
+ for (const file of files) {
6
+ const target = path.join(projectRoot, file.path);
7
+ await fs.mkdir(path.dirname(target), { recursive: true });
8
+ await fs.writeFile(target, file.content, "utf8");
9
+ written.push(file.path);
10
+ }
11
+ return written;
12
+ }