heyio 0.8.0 → 0.9.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.
@@ -13,6 +13,8 @@ import { IO_VERSION } from "../paths.js";
13
13
  import { requireAuth } from "./auth.js";
14
14
  import { listSchedules, getSchedule, deleteSchedule, setScheduleEnabled } from "../store/schedules.js";
15
15
  import { listIoSchedules, getIoSchedule, deleteIoSchedule, setIoScheduleEnabled } from "../store/io-schedules.js";
16
+ import { getScheduleRuns } from "../store/schedule-runs.js";
17
+ import { listPages, readPage } from "../wiki/fs.js";
16
18
  import { runScheduleNow } from "../copilot/scheduler.js";
17
19
  import { runIoScheduleNow } from "../copilot/io-scheduler.js";
18
20
  import { listRecentNotifications, listUnreadNotifications, countUnreadNotifications, markNotificationRead, markAllNotificationsRead, } from "../store/notifications.js";
@@ -452,6 +454,31 @@ export async function startApiServer() {
452
454
  res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
453
455
  }
454
456
  });
457
+ // Schedule run history (issue #65)
458
+ api.get("/schedules/:type/:id/runs", (req, res) => {
459
+ const rawType = Array.isArray(req.params.type) ? req.params.type[0] : req.params.type;
460
+ const id = Number(Array.isArray(req.params.id) ? req.params.id[0] : req.params.id);
461
+ if (Number.isNaN(id)) {
462
+ res.status(400).json({ error: "Invalid schedule id" });
463
+ return;
464
+ }
465
+ const scheduleTypeMap = { squads: "squad", io: "io" };
466
+ const scheduleType = scheduleTypeMap[rawType];
467
+ if (!scheduleType) {
468
+ res.status(400).json({ error: "type must be 'squads' or 'io'" });
469
+ return;
470
+ }
471
+ const rawLimit = Number.parseInt(String(req.query.limit ?? ""), 10);
472
+ const limit = Number.isNaN(rawLimit) ? 25 : Math.min(rawLimit, 100);
473
+ try {
474
+ const runs = getScheduleRuns(scheduleType, id, limit);
475
+ res.json({ runs });
476
+ }
477
+ catch (e) {
478
+ console.error("Error fetching schedule runs:", e);
479
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
480
+ }
481
+ });
455
482
  // Notifications endpoints
456
483
  api.get("/notifications", (_req, res) => {
457
484
  try {
@@ -550,6 +577,45 @@ export async function startApiServer() {
550
577
  sseConnections.delete(res);
551
578
  });
552
579
  });
580
+ // Wiki endpoints (issue #105)
581
+ function extractWikiTitle(pageContent, fallback) {
582
+ const match = pageContent.match(/^#\s+(.+)/m);
583
+ return match ? match[1].trim() : fallback;
584
+ }
585
+ api.get("/wiki", (_req, res) => {
586
+ try {
587
+ const pages = listPages();
588
+ const result = pages.map((pagePath) => {
589
+ const pageContent = readPage(pagePath);
590
+ const title = pageContent ? extractWikiTitle(pageContent, pagePath) : pagePath;
591
+ return { path: pagePath, title };
592
+ });
593
+ res.json({ pages: result });
594
+ }
595
+ catch (e) {
596
+ console.error("Error listing wiki pages:", e);
597
+ res.status(500).json({ error: "Failed to list wiki pages" });
598
+ }
599
+ });
600
+ api.get("/wiki/*", (req, res) => {
601
+ try {
602
+ const pagePath = Array.isArray(req.params[0]) ? req.params[0][0] : req.params[0];
603
+ if (!pagePath) {
604
+ res.status(400).json({ error: "Missing page path" });
605
+ return;
606
+ }
607
+ const pageContent = readPage(pagePath);
608
+ if (pageContent === undefined) {
609
+ res.status(404).json({ error: "Page not found" });
610
+ return;
611
+ }
612
+ res.json({ path: pagePath, content: pageContent });
613
+ }
614
+ catch (e) {
615
+ console.error("Error reading wiki page:", e);
616
+ res.status(500).json({ error: "Failed to read wiki page" });
617
+ }
618
+ });
553
619
  // Mount API at /api (for frontend)
554
620
  app.use("/api", api);
555
621
  // Serve Vue frontend if built assets exist (before backward-compat API mount)
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
2
2
  import { join, basename } from "path";
3
- import { execSync } from "child_process";
3
+ import { execFileSync } from "child_process";
4
4
  import { SKILLS_DIR } from "../paths.js";
5
5
  /**
6
6
  * Scan SKILLS_DIR for subdirectories that contain a SKILL.md file.
@@ -111,7 +111,13 @@ export function parseSkillUrl(input) {
111
111
  if (!input.startsWith("https://")) {
112
112
  throw new Error("Only https:// URLs are supported for SKILL.md installs.");
113
113
  }
114
- const urlObj = new URL(input);
114
+ let urlObj;
115
+ try {
116
+ urlObj = new URL(input);
117
+ }
118
+ catch {
119
+ throw new Error(`Invalid URL: ${input}`);
120
+ }
115
121
  const segments = urlObj.pathname.split("/").filter(Boolean);
116
122
  // Use the segment before SKILL.md, or the hostname as slug fallback
117
123
  const slug = segments.length >= 2
@@ -154,27 +160,44 @@ async function installSkillFromFile(rawUrl, slug) {
154
160
  * Throws if the repo/file does not contain a valid SKILL.md.
155
161
  */
156
162
  export async function installSkill(input) {
157
- const parsed = parseSkillUrl(input);
158
- if (parsed.type === "file") {
159
- return installSkillFromFile(parsed.rawUrl, parsed.slug);
163
+ let destDir;
164
+ try {
165
+ const parsed = parseSkillUrl(input);
166
+ if (parsed.type === "file") {
167
+ return await installSkillFromFile(parsed.rawUrl, parsed.slug);
168
+ }
169
+ const repoUrl = parsed.url;
170
+ const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
171
+ if (!repoName) {
172
+ throw new Error("Could not determine skill name from URL.");
173
+ }
174
+ destDir = join(SKILLS_DIR, repoName);
175
+ execFileSync("git", ["clone", repoUrl, destDir], {
176
+ stdio: "pipe",
177
+ timeout: 60_000,
178
+ });
179
+ const skillMdPath = join(destDir, "SKILL.md");
180
+ if (!existsSync(skillMdPath)) {
181
+ rmSync(destDir, { recursive: true, force: true });
182
+ destDir = undefined;
183
+ throw new Error(`Repository "${repoUrl}" does not contain a SKILL.md file.`);
184
+ }
185
+ const content = readFileSync(skillMdPath, "utf-8");
186
+ const { name, description } = parseSkillMd(content);
187
+ return {
188
+ name: name || repoName,
189
+ slug: repoName,
190
+ description,
191
+ path: destDir,
192
+ };
160
193
  }
161
- const repoUrl = parsed.url;
162
- const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
163
- const destDir = join(SKILLS_DIR, repoName);
164
- execSync(`git clone ${repoUrl} ${destDir}`, { stdio: "pipe" });
165
- const skillMdPath = join(destDir, "SKILL.md");
166
- if (!existsSync(skillMdPath)) {
167
- rmSync(destDir, { recursive: true, force: true });
168
- throw new Error(`Repository "${repoUrl}" does not contain a SKILL.md file.`);
194
+ catch (e) {
195
+ // Clean up partially-created directory on failure
196
+ if (destDir && existsSync(destDir)) {
197
+ rmSync(destDir, { recursive: true, force: true });
198
+ }
199
+ throw e instanceof Error ? e : new Error(String(e));
169
200
  }
170
- const content = readFileSync(skillMdPath, "utf-8");
171
- const { name, description } = parseSkillMd(content);
172
- return {
173
- name: name || repoName,
174
- slug: repoName,
175
- description,
176
- path: destDir,
177
- };
178
201
  }
179
202
  /**
180
203
  * Remove a skill directory by its slug. Returns true if it existed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"