viagen 0.0.16 → 0.0.18

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 CHANGED
@@ -30,7 +30,7 @@ export default defineConfig({
30
30
  npx viagen setup
31
31
  ```
32
32
 
33
- The setup wizard authenticates with Claude then uses GitHub and Vercel to write your local `.env`.
33
+ The setup wizard authenticates with Claude, detects your GitHub and Vercel credentials, and captures your git remote info — all written to your local `.env`. This ensures sandboxes clone the correct repo instead of inferring it at runtime.
34
34
 
35
35
  You can now run `npm run dev` to start the local dev server. At this point you can launch viagen and chat with Claude to make changes to your app.
36
36
 
@@ -68,17 +68,41 @@ viagen({
68
68
  })
69
69
  ```
70
70
 
71
+ ### SSR Frameworks (React Router, Remix, SvelteKit, etc.)
72
+
73
+ For plain Vite apps, the chat panel is injected automatically. SSR frameworks render their own HTML, so you need to add one script tag to your root layout:
74
+
75
+ ```html
76
+ <script src="/via/client.js" defer></script>
77
+ ```
78
+
79
+ For React Router, add it to `app/root.tsx`:
80
+
81
+ ```tsx
82
+ export default function Root() {
83
+ return (
84
+ <html>
85
+ <head>
86
+ <script src="/via/client.js" defer />
87
+ {/* ... */}
88
+ </head>
89
+ {/* ... */}
90
+ </html>
91
+ )
92
+ }
93
+ ```
94
+
71
95
  ### Editable Files
72
96
 
73
97
  Add a file editor panel to the chat UI:
74
98
 
75
99
  ```ts
76
100
  viagen({
77
- editable: ['src/components', '.env', 'vite.config.ts']
101
+ editable: ['src/components', 'vite.config.ts']
78
102
  })
79
103
  ```
80
104
 
81
- Paths can be files or directories (directories include all files within). The editor appears as a "Files" tab in the chat panel. `.env` files get a key-value form with masked values.
105
+ Paths can be files or directories (directories include all files within). The editor appears as a "Files" tab in the chat panel.
82
106
 
83
107
  The default system prompt:
84
108
 
@@ -129,6 +153,8 @@ GET /via/iframe — split view (app + chat side by side)
129
153
  GET /via/files — list editable files (when configured)
130
154
  GET /via/file?path= — read file content
131
155
  POST /via/file — write file content { path, content }
156
+ GET /via/git/status — list changed files (git status)
157
+ GET /via/git/diff — full diff, or single file with ?path=
132
158
  ```
133
159
 
134
160
  When `VIAGEN_AUTH_TOKEN` is set (always on in sandboxes), pass the token as a `Bearer` header or `?token=` query param.
package/dist/cli.js CHANGED
@@ -335,6 +335,13 @@ async function refreshAccessToken(refresh) {
335
335
  }
336
336
 
337
337
  // src/cli.ts
338
+ import {
339
+ createViagen,
340
+ saveCredentials,
341
+ loadCredentials,
342
+ clearCredentials
343
+ } from "viagen-sdk";
344
+ import { spawn as spawnChild } from "child_process";
338
345
  function loadDotenv(dir) {
339
346
  const envPath = join2(dir, ".env");
340
347
  if (!existsSync(envPath)) return {};
@@ -515,9 +522,34 @@ async function setup() {
515
522
  const newVars = {};
516
523
  console.log("viagen setup");
517
524
  console.log("");
518
- if (existing["ANTHROPIC_API_KEY"] || existing["CLAUDE_ACCESS_TOKEN"]) {
525
+ let claudeExpired = false;
526
+ if (existing["CLAUDE_ACCESS_TOKEN"] && existing["CLAUDE_TOKEN_EXPIRES"]) {
527
+ const expires = parseInt(existing["CLAUDE_TOKEN_EXPIRES"], 10);
528
+ const nowSec = Math.floor(Date.now() / 1e3);
529
+ if (nowSec > expires) {
530
+ if (existing["CLAUDE_REFRESH_TOKEN"]) {
531
+ console.log("Claude auth ... token expired, attempting refresh...");
532
+ try {
533
+ const tokens = await refreshAccessToken(existing["CLAUDE_REFRESH_TOKEN"]);
534
+ newVars["CLAUDE_ACCESS_TOKEN"] = tokens.access_token;
535
+ newVars["CLAUDE_REFRESH_TOKEN"] = tokens.refresh_token;
536
+ newVars["CLAUDE_TOKEN_EXPIRES"] = String(nowSec + tokens.expires_in);
537
+ console.log("Claude auth ... refreshed");
538
+ } catch {
539
+ console.log("Claude auth ... refresh failed, need to re-authenticate");
540
+ claudeExpired = true;
541
+ }
542
+ } else {
543
+ console.log("Claude auth ... token expired, need to re-authenticate");
544
+ claudeExpired = true;
545
+ }
546
+ } else {
547
+ console.log("Claude auth ... already configured");
548
+ }
549
+ } else if (existing["ANTHROPIC_API_KEY"]) {
519
550
  console.log("Claude auth ... already configured");
520
- } else {
551
+ }
552
+ if (claudeExpired || !existing["ANTHROPIC_API_KEY"] && !existing["CLAUDE_ACCESS_TOKEN"]) {
521
553
  console.log("How do you want to authenticate with Claude?");
522
554
  console.log("");
523
555
  console.log(" 1) Log in with Claude Max/Pro (recommended)");
@@ -584,8 +616,8 @@ async function setup() {
584
616
  console.log("gh CLI is installed but not logged in.");
585
617
  console.log("Without it, sandboxes can't commit or push changes.");
586
618
  console.log("");
587
- const login = await promptUser("Run gh auth login now? [y/n]: ");
588
- if (login === "y" || login === "yes") {
619
+ const login2 = await promptUser("Run gh auth login now? [y/n]: ");
620
+ if (login2 === "y" || login2 === "yes") {
589
621
  try {
590
622
  execSync2("gh auth login", { stdio: "inherit" });
591
623
  const token = shellOutput("gh auth token");
@@ -601,6 +633,46 @@ async function setup() {
601
633
  }
602
634
  }
603
635
  console.log("");
636
+ {
637
+ const detectedGit = getGitInfo(cwd);
638
+ const savedUrl = existing["GIT_REMOTE_URL"];
639
+ if (savedUrl && detectedGit && savedUrl !== detectedGit.remoteUrl) {
640
+ console.log(`Git repo ... mismatch!`);
641
+ console.log(` .env: ${savedUrl}`);
642
+ console.log(` local: ${detectedGit.remoteUrl}`);
643
+ console.log("");
644
+ const fix = await promptUser("Update .env to match local remote? [y/n]: ");
645
+ if (fix === "y" || fix === "yes" || !fix) {
646
+ newVars["GIT_REMOTE_URL"] = detectedGit.remoteUrl;
647
+ newVars["GIT_BRANCH"] = detectedGit.branch;
648
+ newVars["GIT_USER_NAME"] = detectedGit.userName;
649
+ newVars["GIT_USER_EMAIL"] = detectedGit.userEmail;
650
+ console.log("Git repo ... updated");
651
+ } else {
652
+ console.log("Git repo ... keeping .env value");
653
+ }
654
+ } else if (savedUrl) {
655
+ console.log(`Git repo ... ${savedUrl}`);
656
+ } else if (detectedGit) {
657
+ console.log(`Detected git remote: ${detectedGit.remoteUrl}`);
658
+ console.log(` Branch: ${detectedGit.branch}`);
659
+ console.log(` User: ${detectedGit.userName} <${detectedGit.userEmail}>`);
660
+ console.log("");
661
+ const useIt = await promptUser("Save this to .env? [y/n]: ");
662
+ if (useIt === "y" || useIt === "yes" || !useIt) {
663
+ newVars["GIT_REMOTE_URL"] = detectedGit.remoteUrl;
664
+ newVars["GIT_BRANCH"] = detectedGit.branch;
665
+ newVars["GIT_USER_NAME"] = detectedGit.userName;
666
+ newVars["GIT_USER_EMAIL"] = detectedGit.userEmail;
667
+ console.log("Git repo ... saved");
668
+ } else {
669
+ console.log("Git repo ... skipped");
670
+ }
671
+ } else {
672
+ console.log("Git repo ... not detected (not a git repo or no remote)");
673
+ }
674
+ }
675
+ console.log("");
604
676
  const hasVercel = existing["VERCEL_TOKEN"] && existing["VERCEL_TEAM_ID"] && existing["VERCEL_PROJECT_ID"];
605
677
  if (hasVercel) {
606
678
  console.log("Vercel ... already configured");
@@ -652,8 +724,8 @@ async function setup() {
652
724
  console.log("Vercel CLI is installed but not logged in.");
653
725
  console.log("Sandbox deployment requires Vercel auth.");
654
726
  console.log("");
655
- const login = await promptUser("Run vercel login now? [y/n]: ");
656
- if (login === "y" || login === "yes") {
727
+ const login2 = await promptUser("Run vercel login now? [y/n]: ");
728
+ if (login2 === "y" || login2 === "yes") {
657
729
  try {
658
730
  execSync2("vercel login", { stdio: "inherit" });
659
731
  } catch {
@@ -685,7 +757,17 @@ async function setup() {
685
757
  }
686
758
  console.log("");
687
759
  if (Object.keys(newVars).length > 0) {
688
- writeEnvVars(cwd, newVars);
760
+ const toUpdate = {};
761
+ const toAdd = {};
762
+ for (const [key, val] of Object.entries(newVars)) {
763
+ if (existing[key]) {
764
+ toUpdate[key] = val;
765
+ } else {
766
+ toAdd[key] = val;
767
+ }
768
+ }
769
+ if (Object.keys(toAdd).length > 0) writeEnvVars(cwd, toAdd);
770
+ if (Object.keys(toUpdate).length > 0) updateEnvVars(cwd, toUpdate);
689
771
  console.log("Wrote to .env:");
690
772
  for (const key of Object.keys(newVars)) {
691
773
  const display = key.includes("TOKEN") || key.includes("KEY") || key.includes("SECRET") ? newVars[key].slice(0, 8) + "..." : newVars[key];
@@ -699,6 +781,32 @@ async function setup() {
699
781
  console.log(" npm run dev Start the dev server");
700
782
  console.log(" npx viagen sandbox Deploy to a sandbox");
701
783
  }
784
+ function dev() {
785
+ const child = spawnChild("npx", ["vite"], {
786
+ cwd: process.cwd(),
787
+ stdio: ["inherit", "pipe", "inherit"],
788
+ shell: true
789
+ });
790
+ let opened = false;
791
+ child.stdout?.on("data", (chunk) => {
792
+ const text = chunk.toString();
793
+ process.stdout.write(text);
794
+ if (!opened) {
795
+ const match = text.match(/Local:\s+(https?:\/\/[^\s]+)/);
796
+ if (match) {
797
+ opened = true;
798
+ const baseUrl = match[1].replace(/\/$/, "");
799
+ const iframeUrl = `${baseUrl}/via/iframe`;
800
+ setTimeout(() => openBrowser2(iframeUrl), 500);
801
+ }
802
+ }
803
+ });
804
+ child.on("close", (code) => {
805
+ process.exit(code ?? 0);
806
+ });
807
+ process.on("SIGINT", () => child.kill("SIGINT"));
808
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
809
+ }
702
810
  function parseFlag(args, flag) {
703
811
  const idx = args.indexOf(flag);
704
812
  if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
@@ -768,7 +876,21 @@ async function sandbox(args) {
768
876
  process.exit(1);
769
877
  }
770
878
  const githubToken = env["GITHUB_TOKEN"];
771
- const gitInfo = getGitInfo(cwd);
879
+ const envRemoteUrl = env["GIT_REMOTE_URL"];
880
+ const envBranch = env["GIT_BRANCH"];
881
+ const envUserName = env["GIT_USER_NAME"];
882
+ const envUserEmail = env["GIT_USER_EMAIL"];
883
+ const gitInfo = envRemoteUrl ? {
884
+ remoteUrl: envRemoteUrl,
885
+ branch: envBranch || "main",
886
+ userName: envUserName || "viagen",
887
+ userEmail: envUserEmail || "noreply@viagen.dev",
888
+ isDirty: false
889
+ // can't know from env, assume clean
890
+ } : getGitInfo(cwd);
891
+ if (envRemoteUrl) {
892
+ console.log("Using git info from .env");
893
+ }
772
894
  let deployGit;
773
895
  let overlayFiles;
774
896
  const branch = branchOverride || (gitInfo ? gitInfo.branch : "main");
@@ -804,7 +926,7 @@ async function sandbox(args) {
804
926
  userEmail: gitInfo.userEmail,
805
927
  token: githubToken
806
928
  });
807
- if (gitInfo.isDirty && !branchOverride) {
929
+ if (!envRemoteUrl && gitInfo.isDirty && !branchOverride) {
808
930
  console.log("");
809
931
  console.log("Your working tree has uncommitted changes.");
810
932
  console.log("");
@@ -882,8 +1004,8 @@ async function sandbox(args) {
882
1004
  } : void 0,
883
1005
  timeoutMinutes
884
1006
  });
885
- const iframeUrl = result.url.replace("?token=", "via/iframe?token=");
886
- const chatUrl = result.url.replace("?token=", "via/ui?token=");
1007
+ const iframeUrl = result.url.replace("?token=", "/via/iframe?token=");
1008
+ const chatUrl = result.url.replace("?token=", "/via/ui?token=");
887
1009
  console.log("");
888
1010
  console.log("Sandbox deployed!");
889
1011
  console.log("");
@@ -900,6 +1022,179 @@ async function sandbox(args) {
900
1022
  console.log(`Stop with: npx viagen sandbox stop ${result.sandboxId}`);
901
1023
  openBrowser2(iframeUrl);
902
1024
  }
1025
+ var PLATFORM_URL = "http://localhost:5175";
1026
+ async function login() {
1027
+ const existing = await loadCredentials();
1028
+ if (existing) {
1029
+ const client2 = createViagen({
1030
+ baseUrl: existing.baseUrl,
1031
+ token: existing.token
1032
+ });
1033
+ try {
1034
+ const user2 = await client2.auth.me();
1035
+ if (user2) {
1036
+ console.log(`Already logged in as ${user2.email}`);
1037
+ const answer = await promptUser("Log in again? [y/n]: ");
1038
+ if (answer !== "y" && answer !== "yes") return;
1039
+ }
1040
+ } catch {
1041
+ }
1042
+ }
1043
+ console.log("viagen login");
1044
+ console.log("");
1045
+ const client = createViagen({ baseUrl: PLATFORM_URL });
1046
+ console.log("Opening browser to authorize...");
1047
+ const { token, expiresAt } = await client.auth.loginCli({
1048
+ onOpenUrl: (url) => {
1049
+ openBrowser2(url);
1050
+ console.log("");
1051
+ console.log("If the browser didn't open, visit:");
1052
+ console.log(` ${url}`);
1053
+ console.log("");
1054
+ console.log("Waiting for authorization...");
1055
+ }
1056
+ });
1057
+ await saveCredentials({ token, baseUrl: PLATFORM_URL, expiresAt });
1058
+ const authed = createViagen({ baseUrl: PLATFORM_URL, token });
1059
+ const user = await authed.auth.me();
1060
+ console.log("");
1061
+ if (user) {
1062
+ console.log(`Logged in as ${user.email}`);
1063
+ } else {
1064
+ console.log("Logged in.");
1065
+ }
1066
+ console.log("Credentials saved to ~/.config/viagen/credentials.json");
1067
+ }
1068
+ async function logout() {
1069
+ const existing = await loadCredentials();
1070
+ if (!existing) {
1071
+ console.log("Not logged in.");
1072
+ return;
1073
+ }
1074
+ await clearCredentials();
1075
+ console.log("Logged out. Credentials removed.");
1076
+ }
1077
+ async function whoami() {
1078
+ const existing = await loadCredentials();
1079
+ if (!existing) {
1080
+ console.log("Not logged in. Run `viagen login` to authenticate.");
1081
+ return;
1082
+ }
1083
+ const client = createViagen({
1084
+ baseUrl: existing.baseUrl,
1085
+ token: existing.token
1086
+ });
1087
+ try {
1088
+ const user = await client.auth.me();
1089
+ if (user) {
1090
+ console.log(`${user.email}${user.name ? ` (${user.name})` : ""}`);
1091
+ if (user.organizations.length > 0) {
1092
+ console.log(
1093
+ `Orgs: ${user.organizations.map((o) => o.name).join(", ")}`
1094
+ );
1095
+ }
1096
+ } else {
1097
+ console.log("Session expired. Run `viagen login` to re-authenticate.");
1098
+ }
1099
+ } catch {
1100
+ console.log("Session expired. Run `viagen login` to re-authenticate.");
1101
+ }
1102
+ }
1103
+ async function requireClient() {
1104
+ const creds = await loadCredentials();
1105
+ if (!creds) {
1106
+ console.error("Not logged in. Run `viagen login` first.");
1107
+ process.exit(1);
1108
+ }
1109
+ return createViagen({ baseUrl: creds.baseUrl, token: creds.token });
1110
+ }
1111
+ async function orgs(args) {
1112
+ const sub = args[0];
1113
+ if (sub === "create") {
1114
+ const name = args.slice(1).join(" ");
1115
+ if (!name) {
1116
+ console.error("Usage: viagen orgs create <name>");
1117
+ process.exit(1);
1118
+ }
1119
+ const client2 = await requireClient();
1120
+ const org = await client2.orgs.create({ name });
1121
+ console.log(`Created org "${org.name}" (${org.id})`);
1122
+ return;
1123
+ }
1124
+ if (sub === "invite") {
1125
+ const email = args[1];
1126
+ if (!email) {
1127
+ console.error("Usage: viagen orgs invite <email>");
1128
+ process.exit(1);
1129
+ }
1130
+ const client2 = await requireClient();
1131
+ await client2.orgs.addMember({ email });
1132
+ console.log(`Invited ${email}`);
1133
+ return;
1134
+ }
1135
+ const client = await requireClient();
1136
+ const memberships = await client.orgs.list() ?? [];
1137
+ if (memberships.length === 0) {
1138
+ console.log("No organizations. Create one with `viagen orgs create <name>`.");
1139
+ return;
1140
+ }
1141
+ for (const m of memberships) {
1142
+ const role = m.role ? ` (${m.role})` : "";
1143
+ console.log(` ${m.organizationName}${role}`);
1144
+ }
1145
+ }
1146
+ async function projects(args) {
1147
+ const sub = args[0];
1148
+ if (sub === "create") {
1149
+ const name = args.slice(1).join(" ");
1150
+ if (!name) {
1151
+ console.error("Usage: viagen projects create <name>");
1152
+ process.exit(1);
1153
+ }
1154
+ const client2 = await requireClient();
1155
+ const project = await client2.projects.create({ name });
1156
+ console.log(`Created project "${project.name}" (${project.id})`);
1157
+ return;
1158
+ }
1159
+ if (sub === "get") {
1160
+ const id = args[1];
1161
+ if (!id) {
1162
+ console.error("Usage: viagen projects get <id>");
1163
+ process.exit(1);
1164
+ }
1165
+ const client2 = await requireClient();
1166
+ const project = await client2.projects.get(id);
1167
+ console.log(` Name: ${project.name}`);
1168
+ console.log(` ID: ${project.id}`);
1169
+ if (project.githubRepo) console.log(` GitHub: ${project.githubRepo}`);
1170
+ if (project.templateId) console.log(` Template: ${project.templateId}`);
1171
+ console.log(` Created: ${project.createdAt}`);
1172
+ return;
1173
+ }
1174
+ if (sub === "delete") {
1175
+ const id = args[1];
1176
+ if (!id) {
1177
+ console.error("Usage: viagen projects delete <id>");
1178
+ process.exit(1);
1179
+ }
1180
+ const answer = await promptUser(`Delete project ${id}? [y/n]: `);
1181
+ if (answer !== "y" && answer !== "yes") return;
1182
+ const client2 = await requireClient();
1183
+ await client2.projects.delete(id);
1184
+ console.log("Project deleted.");
1185
+ return;
1186
+ }
1187
+ const client = await requireClient();
1188
+ const list = await client.projects.list() ?? [];
1189
+ if (list.length === 0) {
1190
+ console.log("No projects. Create one with `viagen projects create <name>`.");
1191
+ return;
1192
+ }
1193
+ for (const p of list) {
1194
+ const repo = p.githubRepo ? ` (${p.githubRepo})` : "";
1195
+ console.log(` ${p.name}${repo} ${p.id}`);
1196
+ }
1197
+ }
903
1198
  function help() {
904
1199
  console.log("viagen \u2014 Claude Code in your Vite dev server");
905
1200
  console.log("");
@@ -907,6 +1202,17 @@ function help() {
907
1202
  console.log(" viagen <command>");
908
1203
  console.log("");
909
1204
  console.log("Commands:");
1205
+ console.log(" login Log in to the viagen platform");
1206
+ console.log(" logout Log out and remove credentials");
1207
+ console.log(" whoami Show current user");
1208
+ console.log(" orgs List your organizations");
1209
+ console.log(" orgs create <name> Create a new organization");
1210
+ console.log(" orgs invite <email> Invite a member to the org");
1211
+ console.log(" projects List projects in current org");
1212
+ console.log(" projects create <name> Create a new project");
1213
+ console.log(" projects get <id> Show project details");
1214
+ console.log(" projects delete <id> Delete a project");
1215
+ console.log(" dev Start Vite and open the split view");
910
1216
  console.log(" setup Set up .env with API keys and tokens");
911
1217
  console.log(" sandbox [-b branch] [-t min] Deploy your project to a Vercel Sandbox");
912
1218
  console.log(" sandbox stop <id> Stop a running sandbox");
@@ -932,6 +1238,18 @@ function help() {
932
1238
  console.log(
933
1239
  " GITHUB_TOKEN Enables git commit+push from sandbox."
934
1240
  );
1241
+ console.log(
1242
+ " GIT_REMOTE_URL Git remote (from setup, overrides runtime detection)."
1243
+ );
1244
+ console.log(
1245
+ " GIT_BRANCH Git branch for sandbox."
1246
+ );
1247
+ console.log(
1248
+ " GIT_USER_NAME Git user name for sandbox commits."
1249
+ );
1250
+ console.log(
1251
+ " GIT_USER_EMAIL Git user email for sandbox commits."
1252
+ );
935
1253
  console.log(
936
1254
  " VIAGEN_AUTH_TOKEN Protects all endpoints with token auth."
937
1255
  );
@@ -950,7 +1268,20 @@ function help() {
950
1268
  async function main() {
951
1269
  const args = process.argv.slice(2);
952
1270
  const command = args[0];
953
- if (command === "setup") {
1271
+ if (command === "login") {
1272
+ await login();
1273
+ } else if (command === "logout") {
1274
+ await logout();
1275
+ } else if (command === "whoami") {
1276
+ await whoami();
1277
+ } else if (command === "orgs") {
1278
+ await orgs(args.slice(1));
1279
+ } else if (command === "projects") {
1280
+ await projects(args.slice(1));
1281
+ } else if (command === "dev") {
1282
+ dev();
1283
+ return;
1284
+ } else if (command === "setup") {
954
1285
  await setup();
955
1286
  } else if (command === "sandbox") {
956
1287
  await sandbox(args.slice(1));
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
3
- import { join as join5 } from "path";
3
+ import { join as join6 } from "path";
4
4
  import { loadEnv } from "vite";
5
5
 
6
6
  // src/logger.ts
@@ -112,12 +112,12 @@ async function refreshAccessToken(refresh) {
112
112
 
113
113
  // src/chat.ts
114
114
  function readBody(req) {
115
- return new Promise((resolve2, reject) => {
115
+ return new Promise((resolve3, reject) => {
116
116
  let body = "";
117
117
  req.on("data", (chunk) => {
118
118
  body += chunk.toString();
119
119
  });
120
- req.on("end", () => resolve2(body));
120
+ req.on("end", () => resolve3(body));
121
121
  req.on("error", reject);
122
122
  });
123
123
  }
@@ -343,8 +343,10 @@ function buildClientScript(opts) {
343
343
  /* js */
344
344
  `
345
345
  (function() {
346
+ if (document.getElementById('viagen-toggle')) return;
347
+
346
348
  var OVERLAY_ENABLED = ${opts.overlay};
347
- var EMBED_MODE = ${opts.embedMode ? "true" : "false"};
349
+ var EMBED_MODE = new URLSearchParams(window.location.search).has('_viagen_embed');
348
350
 
349
351
  /* ---- Error overlay: inject Fix button into shadow DOM ---- */
350
352
  if (OVERLAY_ENABLED) {
@@ -492,6 +494,8 @@ function buildClientScript(opts) {
492
494
  // src/ui.ts
493
495
  function buildUiHtml(opts) {
494
496
  const hasEditor = opts?.editable ?? false;
497
+ const hasGit = opts?.git ?? false;
498
+ const hasTabs = hasEditor || hasGit;
495
499
  return `<!DOCTYPE html>
496
500
  <html lang="en">
497
501
  <head>
@@ -605,7 +609,7 @@ function buildUiHtml(opts) {
605
609
  gap: 8px;
606
610
  }
607
611
  .messages:empty::after {
608
- content: 'Ask Claude to build something...';
612
+ content: 'Ask Claude to build features or change something...';
609
613
  color: #3f3f46;
610
614
  font-size: 13px;
611
615
  text-align: center;
@@ -877,6 +881,86 @@ function buildUiHtml(opts) {
877
881
  background: #0a0a0c;
878
882
  border-right: 1px solid #1e1e22;
879
883
  }
884
+ .changes-file {
885
+ padding: 8px 16px;
886
+ font-family: ui-monospace, monospace;
887
+ font-size: 12px;
888
+ color: #a1a1aa;
889
+ cursor: pointer;
890
+ display: flex;
891
+ align-items: center;
892
+ gap: 8px;
893
+ transition: background 0.1s;
894
+ border-bottom: 1px solid #1e1e22;
895
+ }
896
+ .changes-file:hover { background: #18181b; color: #e4e4e7; }
897
+ .changes-file .file-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
898
+ .changes-dot {
899
+ width: 6px;
900
+ height: 6px;
901
+ border-radius: 50%;
902
+ flex-shrink: 0;
903
+ }
904
+ .changes-dot.M { background: #facc15; }
905
+ .changes-dot.A { background: #4ade80; }
906
+ .changes-dot.q { background: #4ade80; }
907
+ .changes-dot.D { background: #f87171; }
908
+ .changes-dot.R { background: #60a5fa; }
909
+ .changes-badge {
910
+ font-size: 10px;
911
+ color: #52525b;
912
+ font-family: ui-monospace, monospace;
913
+ }
914
+ .changes-summary {
915
+ padding: 8px 16px;
916
+ border-bottom: 1px solid #27272a;
917
+ background: #18181b;
918
+ font-family: ui-monospace, monospace;
919
+ font-size: 11px;
920
+ color: #71717a;
921
+ display: flex;
922
+ align-items: center;
923
+ gap: 10px;
924
+ flex-shrink: 0;
925
+ }
926
+ .changes-summary .stat-add { color: #4ade80; }
927
+ .changes-summary .stat-del { color: #f87171; }
928
+ .changes-summary .stat-files { color: #a1a1aa; }
929
+ .file-delta {
930
+ font-family: ui-monospace, monospace;
931
+ font-size: 10px;
932
+ display: flex;
933
+ gap: 4px;
934
+ flex-shrink: 0;
935
+ }
936
+ .file-delta .d-add { color: #4ade80; }
937
+ .file-delta .d-del { color: #f87171; }
938
+ .diff-view {
939
+ flex: 1;
940
+ overflow-y: auto;
941
+ padding: 0;
942
+ font-family: ui-monospace, monospace;
943
+ font-size: 11px;
944
+ line-height: 1.6;
945
+ }
946
+ .diff-line {
947
+ padding: 0 12px;
948
+ white-space: pre-wrap;
949
+ word-break: break-all;
950
+ }
951
+ .diff-add { color: #4ade80; background: rgba(74,222,128,0.08); }
952
+ .diff-del { color: #f87171; background: rgba(248,113,113,0.08); }
953
+ .diff-hunk { color: #a78bfa; background: rgba(167,139,250,0.06); padding-top: 6px; margin-top: 4px; }
954
+ .diff-meta { color: #52525b; }
955
+ .diff-ctx { color: #71717a; }
956
+ .changes-empty {
957
+ padding: 16px;
958
+ color: #52525b;
959
+ font-size: 12px;
960
+ font-family: ui-monospace, monospace;
961
+ text-align: center;
962
+ margin-top: 40%;
963
+ }
880
964
  </style>
881
965
  </head>
882
966
  <body>
@@ -893,9 +977,10 @@ function buildUiHtml(opts) {
893
977
  <button class="btn" id="reset-btn">Reset</button>
894
978
  </div>
895
979
  </div>
896
- ${hasEditor ? `<div class="tab-bar" id="tab-bar">
980
+ ${hasTabs ? `<div class="tab-bar" id="tab-bar">
897
981
  <button class="tab active" data-tab="chat">Chat</button>
898
- <button class="tab" data-tab="files">Files</button>
982
+ ${hasEditor ? '<button class="tab" data-tab="files">Files</button>' : ""}
983
+ ${hasGit ? '<button class="tab" data-tab="changes" id="changes-tab">Changes</button>' : ""}
899
984
  </div>` : ""}
900
985
  <div id="chat-view" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
901
986
  <div class="setup-banner" id="setup-banner"></div>
@@ -922,6 +1007,19 @@ function buildUiHtml(opts) {
922
1007
  </div>
923
1008
  </div>
924
1009
  </div>` : ""}
1010
+ ${hasGit ? `<div id="changes-view" style="display:none;flex-direction:column;flex:1;overflow:hidden;">
1011
+ <div class="changes-summary" id="changes-summary" style="display:none;"></div>
1012
+ <div id="changes-list-view" style="flex:1;overflow-y:auto;">
1013
+ <div id="changes-list" style="padding:0;"></div>
1014
+ </div>
1015
+ <div id="changes-diff-view" style="display:none;flex-direction:column;flex:1;overflow:hidden;">
1016
+ <div class="editor-header">
1017
+ <button class="editor-back" id="diff-back" title="Back to changes">&#x2190;</button>
1018
+ <span class="editor-filename" id="diff-filename"></span>
1019
+ </div>
1020
+ <div class="diff-view" id="diff-content"></div>
1021
+ </div>
1022
+ </div>` : ""}
925
1023
  <script>
926
1024
  var STORAGE_KEY = 'viagen_chatLog';
927
1025
  var SOUND_KEY = 'viagen_sound';
@@ -1369,36 +1467,45 @@ function buildUiHtml(opts) {
1369
1467
 
1370
1468
  loadHistory();
1371
1469
 
1372
- // \u2500\u2500 File editor panel \u2500\u2500
1373
- ${hasEditor ? `
1470
+ // \u2500\u2500 Tab switching \u2500\u2500
1471
+ ${hasTabs ? `
1374
1472
  (function() {
1375
1473
  var chatView = document.getElementById('chat-view');
1376
1474
  var filesView = document.getElementById('files-view');
1475
+ var changesView = document.getElementById('changes-view');
1377
1476
  var tabs = document.querySelectorAll('.tab');
1378
- var fileListView = document.getElementById('file-list-view');
1379
- var fileEditorView = document.getElementById('file-editor-view');
1380
- var editorTextarea = document.getElementById('editor-textarea');
1381
- var lineNumbersEl = document.getElementById('line-numbers');
1382
- var editorWrap = document.getElementById('editor-wrap');
1383
- var editorSave = document.getElementById('editor-save');
1384
- var editorFilename = document.getElementById('editor-filename');
1385
-
1386
- var editorState = { path: '', original: '', modified: false };
1387
1477
 
1388
- // Tab switching
1389
1478
  tabs.forEach(function(tab) {
1390
1479
  tab.addEventListener('click', function() {
1391
1480
  tabs.forEach(function(t) { t.classList.remove('active'); });
1392
1481
  tab.classList.add('active');
1393
1482
  var target = tab.dataset.tab;
1394
1483
  chatView.style.display = target === 'chat' ? 'flex' : 'none';
1395
- filesView.style.display = target === 'files' ? 'flex' : 'none';
1396
- if (target === 'files') loadFileList();
1484
+ if (filesView) filesView.style.display = target === 'files' ? 'flex' : 'none';
1485
+ if (changesView) changesView.style.display = target === 'changes' ? 'flex' : 'none';
1486
+ if (target === 'files' && window._viagenLoadFiles) window._viagenLoadFiles();
1487
+ if (target === 'changes' && window._viagenLoadChanges) window._viagenLoadChanges();
1397
1488
  if (target === 'chat') inputEl.focus();
1398
1489
  });
1399
1490
  });
1491
+ })();
1492
+ ` : ""}
1493
+
1494
+ // \u2500\u2500 File editor panel \u2500\u2500
1495
+ ${hasEditor ? `
1496
+ (function() {
1497
+ var fileListView = document.getElementById('file-list-view');
1498
+ var fileEditorView = document.getElementById('file-editor-view');
1499
+ var editorTextarea = document.getElementById('editor-textarea');
1500
+ var lineNumbersEl = document.getElementById('line-numbers');
1501
+ var editorWrap = document.getElementById('editor-wrap');
1502
+ var editorSave = document.getElementById('editor-save');
1503
+ var editorFilename = document.getElementById('editor-filename');
1504
+
1505
+ var editorState = { path: '', original: '', modified: false };
1400
1506
 
1401
1507
  // File list
1508
+ window._viagenLoadFiles = loadFileList;
1402
1509
  async function loadFileList() {
1403
1510
  var listEl = document.getElementById('file-list');
1404
1511
  listEl.innerHTML = '<div style="padding:16px;color:#52525b;font-size:12px;font-family:ui-monospace,monospace;">Loading...</div>';
@@ -1533,6 +1640,129 @@ function buildUiHtml(opts) {
1533
1640
 
1534
1641
  })();
1535
1642
  ` : ""}
1643
+
1644
+ // \u2500\u2500 Changes panel (git diff) \u2500\u2500
1645
+ ${hasGit ? `
1646
+ (function() {
1647
+ var changesListView = document.getElementById('changes-list-view');
1648
+ var changesDiffView = document.getElementById('changes-diff-view');
1649
+ var changesListEl = document.getElementById('changes-list');
1650
+ var diffContent = document.getElementById('diff-content');
1651
+ var diffFilename = document.getElementById('diff-filename');
1652
+ var changesTab = document.getElementById('changes-tab');
1653
+ var changesSummary = document.getElementById('changes-summary');
1654
+
1655
+ window._viagenLoadChanges = loadChanges;
1656
+
1657
+ async function loadChanges() {
1658
+ changesListView.style.display = 'block';
1659
+ changesDiffView.style.display = 'none';
1660
+ changesSummary.style.display = 'none';
1661
+ changesListEl.innerHTML = '<div style="padding:16px;color:#52525b;font-size:12px;font-family:ui-monospace,monospace;">Loading...</div>';
1662
+ try {
1663
+ var res = await fetch('/via/git/status');
1664
+ var data = await res.json();
1665
+ if (!data.git) {
1666
+ changesListEl.innerHTML = '<div class="changes-empty">Not a git repository</div>';
1667
+ if (changesTab) changesTab.textContent = 'Changes';
1668
+ return;
1669
+ }
1670
+ renderSummary(data);
1671
+ renderChanges(data.files);
1672
+ } catch(e) {
1673
+ changesListEl.innerHTML = '<div style="padding:16px;color:#f87171;font-size:12px;">Failed to load changes</div>';
1674
+ }
1675
+ }
1676
+
1677
+ function renderSummary(data) {
1678
+ var ins = data.insertions || 0;
1679
+ var del = data.deletions || 0;
1680
+ var count = data.files ? data.files.length : 0;
1681
+ if (count === 0) { changesSummary.style.display = 'none'; return; }
1682
+ changesSummary.style.display = 'flex';
1683
+ changesSummary.innerHTML =
1684
+ '<span class="stat-files">' + count + (count === 1 ? ' file' : ' files') + '</span>' +
1685
+ (ins > 0 ? '<span class="stat-add">+' + ins + '</span>' : '') +
1686
+ (del > 0 ? '<span class="stat-del">-' + del + '</span>' : '');
1687
+ }
1688
+
1689
+ function renderChanges(files) {
1690
+ changesListEl.innerHTML = '';
1691
+ if (changesTab) changesTab.textContent = files.length > 0 ? 'Changes (' + files.length + ')' : 'Changes';
1692
+ if (files.length === 0) {
1693
+ changesListEl.innerHTML = '<div class="changes-empty">No changes</div>';
1694
+ return;
1695
+ }
1696
+ files.forEach(function(f) {
1697
+ var item = document.createElement('div');
1698
+ item.className = 'changes-file';
1699
+ var dotClass = f.status === '?' ? 'q' : f.status;
1700
+ var statusLabel = f.status === '?' ? 'Untracked' : f.status === 'M' ? 'Modified' : f.status === 'A' ? 'Added' : f.status === 'D' ? 'Deleted' : f.status === 'R' ? 'Renamed' : f.status;
1701
+ var deltaHtml = '';
1702
+ if (f.insertions > 0 || f.deletions > 0) {
1703
+ deltaHtml = '<span class="file-delta">' +
1704
+ (f.insertions > 0 ? '<span class="d-add">+' + f.insertions + '</span>' : '') +
1705
+ (f.deletions > 0 ? '<span class="d-del">-' + f.deletions + '</span>' : '') +
1706
+ '</span>';
1707
+ }
1708
+ item.innerHTML = '<span class="changes-dot ' + dotClass + '" title="' + statusLabel + '"></span>' +
1709
+ '<span class="file-path" title="' + escapeHtml(f.path) + '">' + escapeHtml(f.path) + '</span>' +
1710
+ deltaHtml;
1711
+ item.addEventListener('click', function() { openDiff(f.path); });
1712
+ changesListEl.appendChild(item);
1713
+ });
1714
+ }
1715
+
1716
+ async function openDiff(path) {
1717
+ changesListView.style.display = 'none';
1718
+ changesDiffView.style.display = 'flex';
1719
+ diffFilename.textContent = path;
1720
+ diffContent.innerHTML = '<div style="padding:16px;color:#52525b;font-size:12px;font-family:ui-monospace,monospace;">Loading diff...</div>';
1721
+
1722
+ try {
1723
+ var res = await fetch('/via/git/diff?path=' + encodeURIComponent(path));
1724
+ var data = await res.json();
1725
+ renderDiff(data.diff);
1726
+ } catch(e) {
1727
+ diffContent.innerHTML = '<div style="padding:16px;color:#f87171;font-size:12px;">Failed to load diff</div>';
1728
+ }
1729
+ }
1730
+
1731
+ function renderDiff(diff) {
1732
+ diffContent.innerHTML = '';
1733
+ if (!diff) {
1734
+ diffContent.innerHTML = '<div class="changes-empty">No diff available</div>';
1735
+ return;
1736
+ }
1737
+ var lines = diff.split('\\n');
1738
+ for (var i = 0; i < lines.length; i++) {
1739
+ var line = lines[i];
1740
+ var div = document.createElement('div');
1741
+ div.className = 'diff-line';
1742
+ if (line.charAt(0) === '+' && !line.startsWith('+++')) {
1743
+ div.className += ' diff-add';
1744
+ } else if (line.charAt(0) === '-' && !line.startsWith('---')) {
1745
+ div.className += ' diff-del';
1746
+ } else if (line.startsWith('@@')) {
1747
+ div.className += ' diff-hunk';
1748
+ } else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
1749
+ div.className += ' diff-meta';
1750
+ } else {
1751
+ div.className += ' diff-ctx';
1752
+ }
1753
+ div.textContent = line;
1754
+ diffContent.appendChild(div);
1755
+ }
1756
+ }
1757
+
1758
+ // Back button
1759
+ document.getElementById('diff-back').addEventListener('click', function() {
1760
+ changesDiffView.style.display = 'none';
1761
+ changesListView.style.display = 'block';
1762
+ });
1763
+
1764
+ })();
1765
+ ` : ""}
1536
1766
  </script>
1537
1767
  </body>
1538
1768
  </html>`;
@@ -1688,12 +1918,12 @@ import {
1688
1918
  } from "fs";
1689
1919
  import { join as join3, resolve, relative } from "path";
1690
1920
  function readBody2(req) {
1691
- return new Promise((resolve2, reject) => {
1921
+ return new Promise((resolve3, reject) => {
1692
1922
  let body = "";
1693
1923
  req.on("data", (chunk) => {
1694
1924
  body += chunk.toString();
1695
1925
  });
1696
- req.on("end", () => resolve2(body));
1926
+ req.on("end", () => resolve3(body));
1697
1927
  req.on("error", reject);
1698
1928
  });
1699
1929
  }
@@ -1830,10 +2060,240 @@ function registerFileRoutes(server, opts) {
1830
2060
  });
1831
2061
  }
1832
2062
 
2063
+ // src/inject.ts
2064
+ var SCRIPT_TAG = '<script src="/via/client.js" defer></script>';
2065
+ var MARKER = "viagen-toggle";
2066
+ function createInjectionMiddleware() {
2067
+ return function injectMiddleware(req, res, next) {
2068
+ if (req.method !== "GET" && req.method !== "HEAD") {
2069
+ return next();
2070
+ }
2071
+ const url = req.url ?? "/";
2072
+ if (url.startsWith("/via/")) {
2073
+ return next();
2074
+ }
2075
+ const accept = req.headers.accept ?? "";
2076
+ if (!accept.includes("text/html")) {
2077
+ return next();
2078
+ }
2079
+ const originalWrite = res.write;
2080
+ const originalEnd = res.end;
2081
+ let injected = false;
2082
+ function isHtmlResponse() {
2083
+ const ct = res.getHeader("content-type");
2084
+ return !!ct && String(ct).includes("text/html");
2085
+ }
2086
+ function tryInject(chunk) {
2087
+ if (injected || !chunk) return chunk;
2088
+ if (!isHtmlResponse()) return chunk;
2089
+ const str = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : null;
2090
+ if (!str) return chunk;
2091
+ if (str.includes(MARKER) || str.includes("/via/client.js")) {
2092
+ injected = true;
2093
+ return chunk;
2094
+ }
2095
+ let idx = str.indexOf("</head>");
2096
+ if (idx === -1) idx = str.indexOf("</body>");
2097
+ if (idx === -1) return chunk;
2098
+ injected = true;
2099
+ if (!res.headersSent) {
2100
+ res.removeHeader("content-length");
2101
+ }
2102
+ const result = str.slice(0, idx) + SCRIPT_TAG + str.slice(idx);
2103
+ return typeof chunk === "string" ? result : Buffer.from(result, "utf-8");
2104
+ }
2105
+ res.write = function(chunk, ...args) {
2106
+ return originalWrite.call(
2107
+ res,
2108
+ tryInject(chunk),
2109
+ ...args
2110
+ );
2111
+ };
2112
+ res.end = function(chunk, ...args) {
2113
+ return originalEnd.call(
2114
+ res,
2115
+ tryInject(chunk),
2116
+ ...args
2117
+ );
2118
+ };
2119
+ next();
2120
+ };
2121
+ }
2122
+
2123
+ // src/git.ts
2124
+ import { readFileSync as readFileSync3 } from "fs";
2125
+ import { join as join4, resolve as resolve2 } from "path";
2126
+ import {
2127
+ simpleGit
2128
+ } from "simple-git";
2129
+ function mapStatus(result, stats) {
2130
+ const files = [];
2131
+ const push = (path, status) => {
2132
+ const s = stats.get(path) ?? { ins: 0, del: 0 };
2133
+ files.push({ path, status, insertions: s.ins, deletions: s.del });
2134
+ };
2135
+ for (const f of result.modified) push(f, "M");
2136
+ for (const f of result.created) push(f, "A");
2137
+ for (const f of result.deleted) push(f, "D");
2138
+ for (const f of result.renamed) push(f.to, "R");
2139
+ for (const f of result.not_added) push(f, "?");
2140
+ const seen = /* @__PURE__ */ new Set();
2141
+ return files.filter((f) => {
2142
+ if (seen.has(f.path)) return false;
2143
+ seen.add(f.path);
2144
+ return true;
2145
+ }).sort((a, b) => a.path.localeCompare(b.path));
2146
+ }
2147
+ async function getDiffStats(git, repoRoot, untrackedFiles) {
2148
+ const stats = /* @__PURE__ */ new Map();
2149
+ let totalInsertions = 0;
2150
+ let totalDeletions = 0;
2151
+ try {
2152
+ const [staged, unstaged] = await Promise.all([
2153
+ git.diffSummary(["--cached"]),
2154
+ git.diffSummary()
2155
+ ]);
2156
+ for (const summary of [staged, unstaged]) {
2157
+ for (const f of summary.files) {
2158
+ if (f.binary) continue;
2159
+ const tf = f;
2160
+ const existing = stats.get(tf.file) ?? { ins: 0, del: 0 };
2161
+ existing.ins += tf.insertions;
2162
+ existing.del += tf.deletions;
2163
+ stats.set(tf.file, existing);
2164
+ }
2165
+ }
2166
+ for (const filePath of untrackedFiles) {
2167
+ try {
2168
+ const content = readFileSync3(join4(repoRoot, filePath), "utf-8");
2169
+ const lines = content.split("\n").length;
2170
+ stats.set(filePath, { ins: lines, del: 0 });
2171
+ } catch {
2172
+ }
2173
+ }
2174
+ for (const { ins, del } of stats.values()) {
2175
+ totalInsertions += ins;
2176
+ totalDeletions += del;
2177
+ }
2178
+ } catch {
2179
+ }
2180
+ return { stats, totalInsertions, totalDeletions };
2181
+ }
2182
+ async function getFileDiff(git, repoRoot, filePath) {
2183
+ const abs = resolve2(repoRoot, filePath);
2184
+ if (!abs.startsWith(repoRoot + "/") && abs !== repoRoot) {
2185
+ return "";
2186
+ }
2187
+ try {
2188
+ const staged = await git.diff(["--cached", "--", filePath]);
2189
+ const unstaged = await git.diff(["--", filePath]);
2190
+ if (staged && unstaged) return staged + "\n" + unstaged;
2191
+ if (staged) return staged;
2192
+ if (unstaged) return unstaged;
2193
+ try {
2194
+ const content = readFileSync3(join4(repoRoot, filePath), "utf-8");
2195
+ const lines = content.split("\n");
2196
+ const added = lines.map((l) => `+${l}`).join("\n");
2197
+ return `--- /dev/null
2198
+ +++ b/${filePath}
2199
+ @@ -0,0 +1,${lines.length} @@
2200
+ ${added}`;
2201
+ } catch {
2202
+ return "";
2203
+ }
2204
+ } catch {
2205
+ return "";
2206
+ }
2207
+ }
2208
+ async function findRepoRoot(cwd) {
2209
+ try {
2210
+ const root = await simpleGit(cwd).revparse(["--show-toplevel"]);
2211
+ return root.trim();
2212
+ } catch {
2213
+ return null;
2214
+ }
2215
+ }
2216
+ function registerGitRoutes(server, opts) {
2217
+ let repoRoot = null;
2218
+ let git = null;
2219
+ async function ensureGit() {
2220
+ if (git && repoRoot) return { git, root: repoRoot };
2221
+ repoRoot = await findRepoRoot(opts.projectRoot);
2222
+ if (!repoRoot) return null;
2223
+ git = simpleGit(repoRoot);
2224
+ return { git, root: repoRoot };
2225
+ }
2226
+ server.middlewares.use("/via/git/status", (req, res) => {
2227
+ if (req.method !== "GET") {
2228
+ res.statusCode = 405;
2229
+ res.setHeader("Content-Type", "application/json");
2230
+ res.end(JSON.stringify({ error: "Method not allowed" }));
2231
+ return;
2232
+ }
2233
+ res.setHeader("Content-Type", "application/json");
2234
+ ensureGit().then(async (ctx) => {
2235
+ if (!ctx) {
2236
+ res.end(JSON.stringify({ files: [], git: false }));
2237
+ return;
2238
+ }
2239
+ const result = await ctx.git.status();
2240
+ const { stats, totalInsertions, totalDeletions } = await getDiffStats(
2241
+ ctx.git,
2242
+ ctx.root,
2243
+ result.not_added
2244
+ );
2245
+ const files = mapStatus(result, stats);
2246
+ res.end(
2247
+ JSON.stringify({
2248
+ files,
2249
+ git: true,
2250
+ insertions: totalInsertions,
2251
+ deletions: totalDeletions
2252
+ })
2253
+ );
2254
+ }).catch(() => {
2255
+ res.end(JSON.stringify({ files: [], git: false }));
2256
+ });
2257
+ });
2258
+ server.middlewares.use("/via/git/diff", (req, res) => {
2259
+ if (req.method !== "GET") {
2260
+ res.statusCode = 405;
2261
+ res.setHeader("Content-Type", "application/json");
2262
+ res.end(JSON.stringify({ error: "Method not allowed" }));
2263
+ return;
2264
+ }
2265
+ res.setHeader("Content-Type", "application/json");
2266
+ const url = new URL(req.url ?? "/", "http://localhost");
2267
+ const filePath = url.searchParams.get("path");
2268
+ ensureGit().then(async (ctx) => {
2269
+ if (!ctx) {
2270
+ res.end(JSON.stringify({ diff: "", git: false }));
2271
+ return;
2272
+ }
2273
+ if (filePath) {
2274
+ if (filePath.startsWith("/")) {
2275
+ res.statusCode = 400;
2276
+ res.end(JSON.stringify({ error: "Absolute paths not allowed" }));
2277
+ return;
2278
+ }
2279
+ const diff = await getFileDiff(ctx.git, ctx.root, filePath);
2280
+ res.end(JSON.stringify({ diff, path: filePath }));
2281
+ } else {
2282
+ const staged = await ctx.git.diff(["--cached"]);
2283
+ const unstaged = await ctx.git.diff();
2284
+ const combined = staged && unstaged ? staged + "\n" + unstaged : staged || unstaged || "";
2285
+ res.end(JSON.stringify({ diff: combined }));
2286
+ }
2287
+ }).catch(() => {
2288
+ res.end(JSON.stringify({ diff: "" }));
2289
+ });
2290
+ });
2291
+ }
2292
+
1833
2293
  // src/sandbox.ts
1834
2294
  import { randomUUID } from "crypto";
1835
- import { readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
1836
- import { join as join4, relative as relative2 } from "path";
2295
+ import { readFileSync as readFileSync4, readdirSync as readdirSync2 } from "fs";
2296
+ import { join as join5, relative as relative2 } from "path";
1837
2297
  import { Sandbox } from "@vercel/sandbox";
1838
2298
  var SKIP_DIRS = /* @__PURE__ */ new Set([
1839
2299
  "node_modules",
@@ -1849,13 +2309,13 @@ function collectFiles2(dir, base) {
1849
2309
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1850
2310
  if (entry.name.startsWith(".") && SKIP_DIRS.has(entry.name)) continue;
1851
2311
  if (SKIP_DIRS.has(entry.name)) continue;
1852
- const fullPath = join4(dir, entry.name);
2312
+ const fullPath = join5(dir, entry.name);
1853
2313
  const relPath = relative2(base, fullPath);
1854
2314
  if (entry.isDirectory()) {
1855
2315
  files.push(...collectFiles2(fullPath, base));
1856
2316
  } else if (entry.isFile()) {
1857
2317
  if (SKIP_FILES.has(entry.name)) continue;
1858
- files.push({ path: relPath, content: readFileSync3(fullPath) });
2318
+ files.push({ path: relPath, content: readFileSync4(fullPath) });
1859
2319
  }
1860
2320
  }
1861
2321
  return files;
@@ -2032,10 +2492,10 @@ function viagen(options) {
2032
2492
  claudeBin = findClaudeBin();
2033
2493
  logBuffer.init(projectRoot);
2034
2494
  wrapLogger(config.logger, logBuffer);
2035
- const viagenDir = join5(projectRoot, ".viagen");
2495
+ const viagenDir = join6(projectRoot, ".viagen");
2036
2496
  mkdirSync2(viagenDir, { recursive: true });
2037
2497
  writeFileSync4(
2038
- join5(viagenDir, "config.json"),
2498
+ join6(viagenDir, "config.json"),
2039
2499
  JSON.stringify({
2040
2500
  sandboxFiles: options?.sandboxFiles ?? [],
2041
2501
  editable: options?.editable ?? []
@@ -2046,21 +2506,7 @@ function viagen(options) {
2046
2506
  if (!opts.ui) return [];
2047
2507
  const url = new URL(ctx.originalUrl || ctx.path, "http://localhost");
2048
2508
  const isEmbed = url.searchParams.has("_viagen_embed");
2049
- if (isEmbed) {
2050
- if (!opts.overlay) return [];
2051
- return [
2052
- {
2053
- tag: "script",
2054
- children: buildClientScript({
2055
- position: opts.position,
2056
- panelWidth: opts.panelWidth,
2057
- overlay: true,
2058
- embedMode: true
2059
- }),
2060
- injectTo: "body"
2061
- }
2062
- ];
2063
- }
2509
+ if (isEmbed && !opts.overlay) return [];
2064
2510
  return [
2065
2511
  {
2066
2512
  tag: "script",
@@ -2097,9 +2543,18 @@ ${payload.err.frame || ""}`
2097
2543
  server.middlewares.use(createAuthMiddleware(authToken));
2098
2544
  }
2099
2545
  const hasEditor = !!(options?.editable && options.editable.length > 0);
2546
+ const clientJs = buildClientScript({
2547
+ position: opts.position,
2548
+ panelWidth: opts.panelWidth,
2549
+ overlay: opts.overlay
2550
+ });
2551
+ server.middlewares.use("/via/client.js", (_req, res) => {
2552
+ res.setHeader("Content-Type", "application/javascript");
2553
+ res.end(clientJs);
2554
+ });
2100
2555
  server.middlewares.use("/via/ui", (_req, res) => {
2101
2556
  res.setHeader("Content-Type", "text/html");
2102
- res.end(buildUiHtml({ editable: hasEditor }));
2557
+ res.end(buildUiHtml({ editable: hasEditor, git: true }));
2103
2558
  });
2104
2559
  server.middlewares.use("/via/iframe", (_req, res) => {
2105
2560
  res.setHeader("Content-Type", "text/html");
@@ -2122,6 +2577,12 @@ ${payload.err.frame || ""}`
2122
2577
  projectRoot
2123
2578
  });
2124
2579
  }
2580
+ registerGitRoutes(server, { projectRoot });
2581
+ if (opts.ui) {
2582
+ return () => {
2583
+ server.middlewares.use(createInjectionMiddleware());
2584
+ };
2585
+ }
2125
2586
  }
2126
2587
  };
2127
2588
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viagen",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Vite dev server plugin that exposes endpoints for chatting with Claude Code SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -41,7 +41,9 @@
41
41
  "dependencies": {
42
42
  "@anthropic-ai/claude-code": "^2.1.42",
43
43
  "@vercel/sandbox": "^1",
44
- "lucide-react": "^0.564.0"
44
+ "lucide-react": "^0.564.0",
45
+ "simple-git": "^3.31.1",
46
+ "viagen-sdk": "^0.0.0"
45
47
  },
46
48
  "license": "MIT",
47
49
  "repository": {