@adriandmitroca/relay 0.0.2 → 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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Turn your backlog into pull requests — automatically.**
4
4
 
5
+ ![Relay Dashboard](screenshot.png)
6
+
5
7
  Relay connects to your existing project management tools, picks up tasks, and uses Claude Code to implement them in isolated git worktrees. You review the diff, click Accept, and a PR appears. That's it.
6
8
 
7
9
  No API keys to manage. No per-token billing. No workflow files to write. Relay runs locally on your machine using your Claude Code subscription (Pro, Max, or Team) and plugs directly into the tools you already use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriandmitroca/relay",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "Autonomous SWE agent — triage and fix issues from Sentry, Asana, Linear, and Jira with Claude",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Daemon } from "./daemon.ts";
4
4
  import { setLogLevel } from "./utils/logger.ts";
5
+ import { IssueDB, ConfigDB } from "./db.ts";
5
6
  import { join, dirname } from "node:path";
6
7
  import { unlinkSync } from "node:fs";
7
8
  import pkg from "../package.json" with { type: "json" };
@@ -21,8 +22,8 @@ function getFlag(name: string): string | undefined {
21
22
  return undefined;
22
23
  }
23
24
 
24
- function resolveConfigPath(): string {
25
- return getFlag("config") ?? join(process.cwd(), "config.json");
25
+ function dataDir(): string {
26
+ return join(process.env.HOME ?? "~", ".relay");
26
27
  }
27
28
 
28
29
  function getCommand(): string {
@@ -52,8 +53,10 @@ async function checkForUpdates() {
52
53
  }
53
54
 
54
55
  async function cmdStart() {
55
- const configPath = resolveConfigPath();
56
- const pidFile = join(process.cwd(), "relay.pid");
56
+ const dir = dataDir();
57
+ await Bun.$`mkdir -p ${dir}`.quiet();
58
+ const pidFile = join(dir, "relay.pid");
59
+ const dbPath = join(dir, "sqlite.db");
57
60
  const logLevel = getFlag("log-level");
58
61
  if (logLevel) setLogLevel(logLevel as "debug" | "info" | "warn" | "error");
59
62
 
@@ -74,7 +77,7 @@ async function cmdStart() {
74
77
  // Silent update check in background
75
78
  checkForUpdates().catch(() => {});
76
79
 
77
- const daemon = new Daemon(configPath);
80
+ const daemon = new Daemon(dbPath);
78
81
 
79
82
  const cleanup = () => {
80
83
  try { unlinkSync(pidFile); } catch {}
@@ -91,7 +94,7 @@ async function cmdStart() {
91
94
  }
92
95
 
93
96
  async function cmdStop() {
94
- const pidFile = join(process.cwd(), "relay.pid");
97
+ const pidFile = join(dataDir(), "relay.pid");
95
98
  const pid = Bun.file(pidFile);
96
99
  if (!(await pid.exists())) {
97
100
  console.log(`${YELLOW}No running daemon found.${RESET}`);
@@ -139,6 +142,83 @@ async function cmdUpdate() {
139
142
  }
140
143
  }
141
144
 
145
+ async function cmdDoctor() {
146
+ let failures = 0;
147
+
148
+ function ok(msg: string) { console.log(` ${GREEN}✓${RESET} ${msg}`); }
149
+ function fail(msg: string, hint?: string) {
150
+ console.log(` ${RED}✗${RESET} ${msg}`);
151
+ if (hint) console.log(` ${DIM}${hint}${RESET}`);
152
+ failures++;
153
+ }
154
+ function warn(msg: string, hint?: string) {
155
+ console.log(` ${YELLOW}⚠${RESET} ${msg}`);
156
+ if (hint) console.log(` ${DIM}${hint}${RESET}`);
157
+ }
158
+
159
+ console.log(`\n${BOLD}relay doctor${RESET} — checking your setup\n`);
160
+
161
+ // 1. Bun
162
+ const bun = await Bun.$`bun --version`.quiet().nothrow();
163
+ if (bun.exitCode === 0) ok(`Bun ${bun.stdout.toString().trim()}`);
164
+ else fail("Bun not found", "Install at https://bun.sh");
165
+
166
+ // 2. Claude CLI
167
+ const claude = await Bun.$`claude --version`.quiet().nothrow();
168
+ if (claude.exitCode === 0) ok(`claude ${claude.stdout.toString().trim()}`);
169
+ else fail("claude CLI not found", "Install Claude Code: https://claude.ai/code");
170
+
171
+ // 3. GitHub CLI
172
+ const gh = await Bun.$`gh auth status`.quiet().nothrow();
173
+ if (gh.exitCode === 0) {
174
+ const line = gh.stderr.toString().split("\n").find(l => l.includes("Logged in"));
175
+ ok(`gh — ${line?.trim() ?? "authenticated"}`);
176
+ } else {
177
+ fail("gh not authenticated", "Run: gh auth login");
178
+ }
179
+
180
+ // 4. Data directory
181
+ const dir = dataDir();
182
+ const dirStat = await Bun.$`test -d ${dir}`.quiet().nothrow();
183
+ if (dirStat.exitCode === 0) ok(`~/.relay/ exists`);
184
+ else warn("~/.relay/ not found", "Will be created automatically on first start");
185
+
186
+ // 5. Database + config
187
+ const dbPath = join(dir, "sqlite.db");
188
+ const dbExists = await Bun.file(dbPath).exists();
189
+ if (dbExists) {
190
+ try {
191
+ const db = new IssueDB(dbPath);
192
+ const configDB = new ConfigDB(db.getDatabase());
193
+ if (configDB.hasConfig()) {
194
+ const workspaces = configDB.getWorkspaces();
195
+ const totalProjects = workspaces.reduce((n, ws) => n + configDB.getProjects(ws.id).length, 0);
196
+ ok(`database — ${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}, ${totalProjects} project${totalProjects !== 1 ? "s" : ""}`);
197
+
198
+ // 6. Check repo paths for each project
199
+ for (const ws of workspaces) {
200
+ for (const proj of configDB.getProjects(ws.id)) {
201
+ const repoStat = await Bun.$`test -d ${join(proj.repo_path, ".git")}`.quiet().nothrow();
202
+ if (repoStat.exitCode === 0) ok(`${proj.key} — repo path valid`);
203
+ else fail(`${proj.key} — repo path not found: ${proj.repo_path}`, "Update the path in Settings");
204
+ }
205
+ }
206
+ } else {
207
+ warn("database exists but no config", "Open http://localhost:7842/setup to configure");
208
+ }
209
+ } catch {
210
+ fail("database exists but could not be read", "Try deleting ~/.relay/sqlite.db and reconfiguring");
211
+ }
212
+ } else {
213
+ warn("no database yet", "Run 'relay start' to create it");
214
+ }
215
+
216
+ console.log();
217
+ if (failures === 0) console.log(`${GREEN}All checks passed.${RESET}\n`);
218
+ else console.log(`${RED}${failures} check${failures !== 1 ? "s" : ""} failed.${RESET}\n`);
219
+ if (failures > 0) process.exit(1);
220
+ }
221
+
142
222
  function showHelp() {
143
223
  console.log(`
144
224
  ${BOLD}Relay${RESET} v${VERSION} — Autonomous SWE agent
@@ -146,18 +226,18 @@ ${BOLD}Relay${RESET} v${VERSION} — Autonomous SWE agent
146
226
  ${BOLD}Usage:${RESET}
147
227
  relay start Start the daemon (opens dashboard at localhost:7842)
148
228
  relay stop Stop the daemon
229
+ relay doctor Check your setup for problems
149
230
  relay update Update to the latest version
150
231
  relay version Show version
151
232
  relay help Show this help
152
233
 
153
234
  ${BOLD}Options:${RESET}
154
- --config <path> Path to config.json (default: ./config.json)
155
235
  --log-level <level> Override log level (debug/info/warn/error)
156
236
 
157
237
  ${BOLD}Getting started:${RESET}
158
- bunx relayd Try without installing
159
- bun install -g relayd Install globally
160
- relay start Start — configure via the setup wizard at localhost:7842
238
+ bunx @adriandmitroca/relay Try without installing
239
+ bun install -g @adriandmitroca/relay Install globally
240
+ relay start Start — configure via the setup wizard at localhost:7842
161
241
  `);
162
242
  }
163
243
 
@@ -171,6 +251,9 @@ switch (cmd) {
171
251
  case "stop":
172
252
  await cmdStop();
173
253
  break;
254
+ case "doctor":
255
+ await cmdDoctor();
256
+ break;
174
257
  case "update":
175
258
  await cmdUpdate();
176
259
  break;
package/src/config.ts CHANGED
@@ -78,34 +78,6 @@ const DEFAULTS = {
78
78
  allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
79
79
  };
80
80
 
81
- export async function loadConfig(configPath: string): Promise<Config> {
82
- const file = Bun.file(configPath);
83
- if (!(await file.exists())) {
84
- throw new Error(`Config not found at ${configPath}. Run 'relay init' to create one.`);
85
- }
86
-
87
- let raw: Record<string, unknown>;
88
- try {
89
- raw = await file.json();
90
- } catch {
91
- throw new Error(`Config at ${configPath} is not valid JSON. Check for syntax errors.`);
92
- }
93
-
94
- const config: Config = {
95
- workspaces: (raw.workspaces as WorkspaceConfig[]) ?? [],
96
- pollIntervalSeconds: (raw.pollIntervalSeconds as number) ?? DEFAULTS.pollIntervalSeconds,
97
- maxConcurrency: (raw.maxConcurrency as number) ?? DEFAULTS.maxConcurrency,
98
- claudeTimeout: (raw.claudeTimeout as number) ?? DEFAULTS.claudeTimeout,
99
- triageTimeout: (raw.triageTimeout as number) ?? DEFAULTS.triageTimeout,
100
- triage: (raw.triage as boolean) ?? DEFAULTS.triage,
101
- logLevel: (raw.logLevel as Config["logLevel"]) ?? DEFAULTS.logLevel,
102
- allowedTools: (raw.allowedTools as string[]) ?? DEFAULTS.allowedTools,
103
- };
104
-
105
- validate(config);
106
- return config;
107
- }
108
-
109
81
  export function findProjectConfig(config: Config, projectKey: string): { workspace: WorkspaceConfig; project: ProjectConfig } | null {
110
82
  for (const ws of config.workspaces) {
111
83
  const project = ws.projects.find((p) => p.key === projectKey);
package/src/daemon.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { loadConfig, allProjects, findProjectConfig, type Config, type ProjectConfig, type WorkspaceConfig } from "./config.ts";
1
+ import { allProjects, findProjectConfig, type Config, type ProjectConfig, type WorkspaceConfig } from "./config.ts";
2
2
  import { IssueDB, ConfigDB, type IssueRow, type IssueStatus } from "./db.ts";
3
3
  import { PriorityQueue } from "./queue.ts";
4
4
  import { SentryAdapter } from "./sources/sentry.ts";
@@ -44,33 +44,19 @@ export class Daemon {
44
44
  private queue!: PriorityQueue<PipelineItem>;
45
45
  private workspaces: Map<string, WorkspaceRuntime> = new Map();
46
46
  private pollTimer: ReturnType<typeof setInterval> | null = null;
47
- private configWatcher: ReturnType<typeof import("node:fs").watch> | null = null;
48
47
  private stopping = false;
49
- private configPath: string;
48
+ private dbPath: string;
50
49
  private dashboard!: DashboardServer;
51
- private baseDir: string;
52
50
 
53
- constructor(configPath: string) {
54
- this.configPath = configPath;
55
- this.baseDir = dirname(configPath);
51
+ constructor(dbPath: string) {
52
+ this.dbPath = dbPath;
56
53
  }
57
54
 
58
55
  async start() {
59
56
  // Init DB
60
- const dbPath = join(this.baseDir, "sqlite.db");
61
- this.db = new IssueDB(dbPath);
57
+ this.db = new IssueDB(this.dbPath);
62
58
  this.configDB = new ConfigDB(this.db.getDatabase());
63
59
 
64
- // Auto-import config.json if DB has no config
65
- if (!this.configDB.hasConfig()) {
66
- const configFile = Bun.file(this.configPath);
67
- if (await configFile.exists()) {
68
- const jsonConfig = await loadConfig(this.configPath);
69
- this.configDB.importFromJson(jsonConfig);
70
- logger.info("Auto-imported config.json into database");
71
- }
72
- }
73
-
74
60
  // Start dashboard (always — even without config for setup wizard)
75
61
  const isDev = process.env.DEV === "1";
76
62
  // Resolve dist/ relative to this source file so it works both in-repo and when
@@ -129,9 +115,6 @@ export class Daemon {
129
115
  // Start poll interval
130
116
  this.pollTimer = setInterval(() => this.poll(), this.config.pollIntervalSeconds * 1000);
131
117
 
132
- // Watch config.json for hot reload (legacy support)
133
- this.watchConfig();
134
-
135
118
  // Setup shutdown handlers
136
119
  this.setupShutdownHandlers();
137
120
 
@@ -223,11 +206,6 @@ export class Daemon {
223
206
  this.stopping = true;
224
207
  logger.info("Shutting down...");
225
208
 
226
- if (this.configWatcher) {
227
- this.configWatcher.close();
228
- this.configWatcher = null;
229
- }
230
-
231
209
  if (this.pollTimer) {
232
210
  clearInterval(this.pollTimer);
233
211
  this.pollTimer = null;
@@ -330,58 +308,6 @@ export class Daemon {
330
308
  }
331
309
  }
332
310
 
333
- private watchConfig() {
334
- const { watch } = require("node:fs") as typeof import("node:fs");
335
- let debounce: ReturnType<typeof setTimeout> | null = null;
336
-
337
- this.configWatcher = watch(this.configPath, () => {
338
- if (debounce) clearTimeout(debounce);
339
- debounce = setTimeout(() => this.reloadConfig(), 500);
340
- });
341
-
342
- logger.debug("Watching config for changes", { path: this.configPath });
343
- }
344
-
345
- private async reloadConfig() {
346
- try {
347
- const newConfig = await loadConfig(this.configPath);
348
-
349
- // Restart poll timer if interval changed
350
- if (newConfig.pollIntervalSeconds !== this.config.pollIntervalSeconds) {
351
- if (this.pollTimer) clearInterval(this.pollTimer);
352
- this.pollTimer = setInterval(() => this.poll(), newConfig.pollIntervalSeconds * 1000);
353
- logger.info("Poll interval updated", { seconds: newConfig.pollIntervalSeconds });
354
- }
355
-
356
- if (newConfig.logLevel !== this.config.logLevel) {
357
- setLogLevel(newConfig.logLevel);
358
- logger.info("Log level updated", { level: newConfig.logLevel });
359
- }
360
-
361
- // Reinit workspaces if workspace config changed
362
- const oldWsJson = JSON.stringify(this.config.workspaces);
363
- const newWsJson = JSON.stringify(newConfig.workspaces);
364
- if (oldWsJson !== newWsJson) {
365
- for (const ws of this.workspaces.values()) {
366
- ws.telegram?.stopPolling();
367
- }
368
- this.workspaces.clear();
369
- this.config = newConfig;
370
- this.initWorkspaces();
371
- logger.info("Workspaces reinitialized");
372
- } else {
373
- this.config = newConfig;
374
- }
375
-
376
- logger.info("Config reloaded");
377
-
378
- // Trigger immediate poll after reload
379
- await this.poll();
380
- } catch (err) {
381
- logger.error("Config reload failed, keeping current config", { error: String(err) });
382
- }
383
- }
384
-
385
311
  private async poll() {
386
312
  if (this.stopping) return;
387
313
 
@@ -1066,31 +992,46 @@ export class Daemon {
1066
992
  function buildPRBody(row: IssueRow): string {
1067
993
  const lines: string[] = [];
1068
994
 
1069
- if (row.externalUrl) {
1070
- lines.push("## Source");
1071
- lines.push(row.externalUrl);
995
+ // Summary
996
+ lines.push("## Summary");
997
+ if (row.fixSummary) {
998
+ lines.push(row.fixSummary);
999
+ } else {
1000
+ lines.push(`Automated fix for: ${row.title}`);
1001
+ }
1002
+ lines.push("");
1003
+
1004
+ // Context — triage plan if available
1005
+ if (row.triagePlan) {
1006
+ lines.push("## Context");
1007
+ lines.push(row.triagePlan);
1072
1008
  lines.push("");
1073
1009
  }
1074
1010
 
1075
- lines.push("## Summary");
1076
- lines.push(row.fixSummary ?? "Automated changes.");
1077
- lines.push("");
1011
+ // Source link
1012
+ if (row.externalUrl) {
1013
+ const sourceLabel = row.source.charAt(0).toUpperCase() + row.source.slice(1);
1014
+ lines.push(`**${sourceLabel}:** ${row.externalUrl}`);
1015
+ lines.push("");
1016
+ }
1078
1017
 
1018
+ // Test plan
1079
1019
  lines.push("## Test plan");
1080
1020
  lines.push("- [ ] Verify the changes resolve the issue");
1081
- lines.push("- [ ] Run existing tests");
1082
- lines.push("- [ ] Manual QA");
1021
+ lines.push("- [ ] Existing tests pass");
1022
+ lines.push("- [ ] Manual QA if applicable");
1083
1023
  lines.push("");
1084
1024
 
1025
+ // Signature
1085
1026
  lines.push("---");
1086
- lines.push("🤖 Generated with [Claude Code](https://claude.com/claude-code) · Enhanced by [Relay](https://github.com/adriandmitroca/relay)");
1027
+ lines.push("🤖 Generated with [Relay](https://github.com/adriandmitroca/relay) using [Claude Code](https://claude.com/claude-code)");
1087
1028
 
1088
1029
  return lines.join("\n");
1089
1030
  }
1090
1031
 
1091
1032
  // When run directly
1092
1033
  if (import.meta.main) {
1093
- const configPath = process.argv[2] || join(process.cwd(), "config.json");
1094
- const daemon = new Daemon(configPath);
1034
+ const dbPath = process.argv[2] || join(process.env.HOME ?? "~", ".relay", "sqlite.db");
1035
+ const daemon = new Daemon(dbPath);
1095
1036
  await daemon.start();
1096
1037
  }
package/src/db.ts CHANGED
@@ -615,60 +615,6 @@ export class ConfigDB {
615
615
  return row.count > 0;
616
616
  }
617
617
 
618
- importFromJson(config: Config): void {
619
- this.db.exec("BEGIN TRANSACTION");
620
- try {
621
- // Import settings
622
- const settingsMap: Record<string, string> = {
623
- pollIntervalSeconds: String(config.pollIntervalSeconds),
624
- maxConcurrency: String(config.maxConcurrency),
625
- claudeTimeout: String(config.claudeTimeout),
626
- triageTimeout: String(config.triageTimeout),
627
- triage: String(config.triage),
628
- logLevel: config.logLevel,
629
- allowedTools: JSON.stringify(config.allowedTools),
630
- };
631
- this.updateSettings(settingsMap);
632
-
633
- // Import workspaces
634
- for (const ws of config.workspaces) {
635
- const wsRow = this.createWorkspace(ws.key);
636
-
637
- if (ws.telegram) {
638
- this.upsertTelegramConfig(wsRow.id, ws.telegram.botToken, ws.telegram.chatId);
639
- }
640
-
641
- for (const proj of ws.projects) {
642
- const projRow = this.createProject(wsRow.id, {
643
- key: proj.key,
644
- repoPath: proj.repoPath,
645
- baseBranch: proj.baseBranch,
646
- testCommand: proj.testCommand,
647
- });
648
-
649
- if (proj.sources.sentry) {
650
- this.upsertSourceConfig(projRow.id, "sentry", proj.sources.sentry as unknown as Record<string, unknown>);
651
- }
652
- if (proj.sources.asana) {
653
- this.upsertSourceConfig(projRow.id, "asana", proj.sources.asana as unknown as Record<string, unknown>);
654
- }
655
- if (proj.sources.linear) {
656
- this.upsertSourceConfig(projRow.id, "linear", proj.sources.linear as unknown as Record<string, unknown>);
657
- }
658
- if (proj.sources.jira) {
659
- this.upsertSourceConfig(projRow.id, "jira", proj.sources.jira as unknown as Record<string, unknown>);
660
- }
661
- }
662
- }
663
-
664
- this.db.exec("COMMIT");
665
- logger.info("Imported config from JSON to DB", { workspaces: config.workspaces.length });
666
- } catch (err) {
667
- this.db.exec("ROLLBACK");
668
- throw err;
669
- }
670
- }
671
-
672
618
  toConfig(): Config {
673
619
  const settings = this.getSettings();
674
620
  const workspaces: WorkspaceConfig[] = [];