@lovenyberg/ove 0.5.1 → 0.5.2

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/src/handlers.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { mkdirSync } from "node:fs";
1
2
  import { parseMessage, buildContextualPrompt } from "./router";
2
3
  import type { ParsedMessage } from "./router";
3
4
  import { isAuthorized, getUserRepos, addRepo, addUser } from "./config";
@@ -332,6 +333,7 @@ async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, histor
332
333
  await msg.updateStatus("Thinking...");
333
334
 
334
335
  try {
336
+ mkdirSync(deps.config.reposDir, { recursive: true });
335
337
  const discussRunner = deps.getRunner(deps.config.runner?.name);
336
338
  const result = await discussRunner.run(
337
339
  prompt,
@@ -452,7 +454,7 @@ async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, de
452
454
  }
453
455
  }
454
456
  } else {
455
- const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
457
+ const reply = `No repos configured yet. Add one with:\n\`init repo <name> <git-url> [branch]\`\nExample: \`init repo my-app git@github.com:user/my-app.git\``;
456
458
  await msg.reply(reply);
457
459
  return;
458
460
  }
@@ -521,6 +523,13 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage)
521
523
  return;
522
524
  }
523
525
 
526
+ // If user has no repos, fall back to discuss mode so they can still chat
527
+ const userRepos = getUserRepos(deps.config, msg.userId);
528
+ if (userRepos.length === 0) {
529
+ const history = deps.sessions.getHistory(msg.userId, 6);
530
+ return handleDiscuss(msg, { ...parsed, type: "discuss", args: { topic: parsed.rawText } }, history, deps);
531
+ }
532
+
524
533
  // For all other types (free-form, review-pr, fix-issue, simplify, validate) — task dispatch
525
534
  await handleTaskMessage(msg, parsed, deps);
526
535
  };
package/src/router.ts CHANGED
@@ -116,6 +116,46 @@ export function parseMessage(text: string): ParsedMessage {
116
116
  };
117
117
  }
118
118
 
119
+ // Natural language repo setup: "clone org/repo", "setup org/repo", "add org/repo"
120
+ const naturalRepoMatch = trimmed.match(/^(?:clone|setup|add|init|use)\s+(?:(?:the|repo)\s+)?([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\s+(?:repo(?:sitory)?))?\s*$/i);
121
+ if (naturalRepoMatch) {
122
+ const slug = naturalRepoMatch[1];
123
+ const name = slug.split("/").pop()!;
124
+ const url = `git@github.com:${slug}.git`;
125
+ return {
126
+ type: "init-repo",
127
+ args: { name, url, branch: "main" },
128
+ rawText: trimmed,
129
+ };
130
+ }
131
+
132
+ // Detect org/repo or GitHub URLs anywhere in a message that looks like a setup request
133
+ const setupIntent = /(?:clone|setup|add|init|use|start\s+(?:with|on)|work\s+(?:on|with))/i.test(lower);
134
+ if (setupIntent) {
135
+ const ghUrl = trimmed.match(/((?:git@github\.com:|https:\/\/github\.com\/)([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+?)(?:\.git)?)\b/);
136
+ if (ghUrl) {
137
+ const slug = ghUrl[2];
138
+ const name = slug.split("/").pop()!;
139
+ const url = ghUrl[1].endsWith(".git") ? ghUrl[1] : ghUrl[1] + ".git";
140
+ return {
141
+ type: "init-repo",
142
+ args: { name, url: url.startsWith("git@") ? url : `git@github.com:${slug}.git`, branch: "main" },
143
+ rawText: trimmed,
144
+ };
145
+ }
146
+ const slugMatch = trimmed.match(/\b([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\b/);
147
+ if (slugMatch && slugMatch[1].indexOf("/") > 0) {
148
+ const slug = slugMatch[1];
149
+ const name = slug.split("/").pop()!;
150
+ const url = `git@github.com:${slug}.git`;
151
+ return {
152
+ type: "init-repo",
153
+ args: { name, url, branch: "main" },
154
+ rawText: trimmed,
155
+ };
156
+ }
157
+ }
158
+
119
159
  const repoHint = trimmed.match(/(?:in|on)\s+(\S+?)[?.!,]*\s*$/i);
120
160
  return { type: "free-form", repo: repoHint?.[1], args: {}, rawText: trimmed };
121
161
  }
package/src/setup.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import { resolve } from "node:path";
3
+ import { resolve, dirname } from "node:path";
4
4
  import { userInfo } from "node:os";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import type { Config } from "./config";
@@ -59,7 +59,9 @@ export function validateConfig(opts?: { configPath?: string; envPath?: string })
59
59
  if (existsSync(configPath)) {
60
60
  try {
61
61
  const config = JSON.parse(readFileSync(configPath, "utf-8")) as Partial<Config>;
62
- if (!config.repos || Object.keys(config.repos).length === 0) {
62
+ const hasGithubSync = config.github?.orgs && config.github.orgs.length > 0;
63
+ const hasWildcardUser = Object.values(config.users || {}).some(u => u.repos?.includes("*"));
64
+ if (!hasGithubSync && !hasWildcardUser && (!config.repos || Object.keys(config.repos).length === 0)) {
63
65
  issues.push("No repos configured");
64
66
  }
65
67
  if (!config.users || Object.keys(config.users).length === 0) {
@@ -236,23 +238,42 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
236
238
  }
237
239
  }
238
240
 
239
- // Collect repos
241
+ // Collect repos — GitHub sync or manual
240
242
  const repos: Record<string, { url: string; defaultBranch: string }> = existingConfig.repos
241
243
  ? { ...existingConfig.repos }
242
244
  : {};
245
+ let githubOrgs: string[] = existingConfig.github?.orgs || [];
246
+ let useWildcard = false;
243
247
 
244
248
  if (needsRepos || needsConfigFile) {
245
- let addMore = true;
246
- while (addMore) {
247
- process.stdout.write("\n Add a repo:\n");
248
- const name = await ask(rl, "Name");
249
- if (!name) break;
250
- const url = await ask(rl, "Git URL");
251
- const branch = (await ask(rl, "Default branch [main]")) || "main";
252
- repos[name] = { url, defaultBranch: branch };
253
-
254
- const again = await ask(rl, "Add another repo? (y/n)");
255
- addMore = again.toLowerCase() === "y";
249
+ const repoMode = await choose(rl, "How do you want to manage repos?", [
250
+ "Auto-discover from GitHub (uses gh CLI)",
251
+ "Add repos manually",
252
+ "Skip add repos later via chat",
253
+ ]);
254
+
255
+ if (repoMode === 0) {
256
+ // GitHub auto-discovery
257
+ const orgs = await ask(rl, "GitHub orgs/users to sync (comma-separated, or empty for your repos)");
258
+ githubOrgs = orgs ? orgs.split(",").map(s => s.trim()).filter(Boolean) : [];
259
+ useWildcard = true;
260
+ process.stdout.write(" Repos will sync from GitHub on startup.\n");
261
+ } else if (repoMode === 1) {
262
+ let addMore = true;
263
+ while (addMore) {
264
+ process.stdout.write("\n Add a repo:\n");
265
+ const name = await ask(rl, "Name");
266
+ if (!name) break;
267
+ const url = await ask(rl, "Git URL");
268
+ const branch = (await ask(rl, "Default branch [main]")) || "main";
269
+ repos[name] = { url, defaultBranch: branch };
270
+
271
+ const again = await ask(rl, "Add another repo? (y/n)");
272
+ addMore = again.toLowerCase() === "y";
273
+ }
274
+ } else {
275
+ useWildcard = true;
276
+ process.stdout.write(" You can add repos later: 'clone org/repo' in chat.\n");
256
277
  }
257
278
  }
258
279
 
@@ -260,7 +281,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
260
281
  const users: Record<string, { name: string; repos: string[] }> = existingConfig.users
261
282
  ? { ...existingConfig.users }
262
283
  : {};
263
- const repoNames = Object.keys(repos);
284
+ const repoNames = useWildcard ? ["*"] : Object.keys(repos);
264
285
 
265
286
  if (needsUsers || needsConfigFile) {
266
287
  // Ask for user name once
@@ -276,7 +297,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
276
297
  if (userId) users[`slack:${userId}`] = { name: userName || "user", repos: repoNames };
277
298
  }
278
299
  if (has("Telegram")) {
279
- const userId = await ask(rl, "Your Telegram user ID");
300
+ const userId = await ask(rl, "Your Telegram user ID (send /start to @userinfobot to find it)");
280
301
  if (userId) users[`telegram:${userId}`] = { name: userName || "user", repos: repoNames };
281
302
  }
282
303
  if (has("Discord")) {
@@ -378,6 +399,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
378
399
  repos,
379
400
  users,
380
401
  claude: existingConfig.claude || { maxTurns: 25 },
402
+ ...(githubOrgs.length > 0 ? { github: { orgs: githubOrgs } } : existingConfig.github ? { github: existingConfig.github } : {}),
381
403
  ...(existingConfig.mcpServers ? { mcpServers: existingConfig.mcpServers } : {}),
382
404
  ...(existingConfig.cron ? { cron: existingConfig.cron } : {}),
383
405
  };
@@ -446,6 +468,18 @@ async function installSystemdService(rl: ReturnType<typeof createInterface>): Pr
446
468
  }
447
469
 
448
470
  const envPath = resolve(workDir, ".env");
471
+
472
+ // Build PATH that includes dirs for bun, claude, and gh
473
+ const pathDirs = new Set<string>();
474
+ for (const bin of [bunPath, "claude", "gh"]) {
475
+ let resolved = "";
476
+ try { resolved = execFileSync("which", [bin]).toString().trim(); } catch {}
477
+ if (resolved) pathDirs.add(dirname(resolved));
478
+ }
479
+ // Always include standard dirs
480
+ for (const d of ["/usr/local/bin", "/usr/bin", "/bin"]) pathDirs.add(d);
481
+ const pathLine = Array.from(pathDirs).join(":");
482
+
449
483
  const service = `[Unit]
450
484
  Description=Ove - Personal AI coding assistant
451
485
  After=network.target
@@ -458,6 +492,7 @@ ExecStart=${bunPath} run src/index.ts
458
492
  Restart=always
459
493
  RestartSec=5
460
494
  EnvironmentFile=${envPath}
495
+ Environment=PATH=${pathLine}
461
496
 
462
497
  [Install]
463
498
  WantedBy=multi-user.target