feat(02-02): create GitHub REST API skill fetcher and database skill CRUD

- Add skill-github.ts with resolveGitHubCommitSha and fetchGitHubSkills
- Add migration 16 for agent_skill_assignments table with FKs and cascade delete
- Add 9 skill CRUD methods to AppDatabase (upsert, list, get, delete, assign, remove)
- Add agent_skill_assignments cleanup to deleteCompany cascade

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-26 00:35:43 -04:00
1 parent 3a1698195d
commit cae241a978
2 files changed
+330

No files matched your search

+168
View File
@@ -6,6 +6,7 @@ import log from "electron-log/main.js";
import { DatabaseSync, backup as backupDatabase, type SQLInputValue } from "node:sqlite";
import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors";
import { SecretVault } from "./vault";
import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "./skill-types.js";
import type {
ActivityRecord,
AgentMessageRecord,
@@ -1407,6 +1408,14 @@ export class AppDatabase {
// New table cleanup (budget_incidents before budget_policies due to FK)
this.db.prepare("delete from budget_incidents where company_id = ?").run(companyId);
this.db.prepare("delete from budget_policies where company_id = ?").run(companyId);
// agent_skill_assignments references company_skills -- delete before skills
try {
this.db.prepare(
"delete from agent_skill_assignments where skill_id in (select id from company_skills where company_id = ?)",
).run(companyId);
} catch (err) {
if (!(err instanceof Error) || !err.message.includes("no such table")) throw err;
}
this.db.prepare("delete from company_skills where company_id = ?").run(companyId);
this.db.prepare("delete from task_sessions where company_id = ?").run(companyId);
this.db.prepare("delete from wakeup_requests where company_id = ?").run(companyId);
@@ -2139,6 +2148,150 @@ export class AppDatabase {
this.db.prepare("update agents set status = ?, updated_at = ? where id = ?").run(status, ts, agentId);
}
// ---------------------------------------------------------------------------
// Skill CRUD methods
// ---------------------------------------------------------------------------
/**
* Upsert a company skill -- INSERT with ON CONFLICT UPDATE.
* Returns isUpdate=true if the record already existed and was updated.
*/
upsertCompanySkill(skill: {
id: string;
companyId: string;
key: string;
slug: string;
name: string;
description: string | null;
markdown: string;
sourceType: string;
sourceLocator: string | null;
sourceRef: string | null;
trustLevel: string;
compatibility: string;
fileInventory: string;
metadata: string | null;
createdAt: string;
updatedAt: string;
}): { isUpdate: boolean } {
// Check if record exists before upsert to determine isUpdate
const existing = this.db
.prepare("SELECT 1 FROM company_skills WHERE company_id = ? AND key = ?")
.get(skill.companyId, skill.key);
const isUpdate = existing != null;
this.db.prepare(`
INSERT INTO company_skills (
id, company_id, key, slug, name, description, markdown,
source_type, source_locator, source_ref, trust_level,
compatibility, file_inventory, metadata, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(company_id, key) DO UPDATE SET
slug = excluded.slug,
name = excluded.name,
description = excluded.description,
markdown = excluded.markdown,
source_ref = excluded.source_ref,
trust_level = excluded.trust_level,
file_inventory = excluded.file_inventory,
metadata = excluded.metadata,
updated_at = excluded.updated_at
`).run(
skill.id, skill.companyId, skill.key, skill.slug,
skill.name, skill.description, skill.markdown,
skill.sourceType, skill.sourceLocator, skill.sourceRef,
skill.trustLevel, skill.compatibility, skill.fileInventory,
skill.metadata, skill.createdAt, skill.updatedAt,
);
return { isUpdate };
}
/** List all skills for a company. */
listCompanySkills(companyId: string): SkillRecord[] {
const rows = this.readAll<SqliteRow>(
"SELECT * FROM company_skills WHERE company_id = ? ORDER BY created_at ASC",
companyId,
);
return rows.map((row) => this.mapSkillRow(row));
}
/** Get a single skill by ID within a company. */
getCompanySkill(companyId: string, skillId: string): SkillRecord | null {
const row = this.db
.prepare("SELECT * FROM company_skills WHERE id = ? AND company_id = ?")
.get(skillId, companyId) as SqliteRow | undefined;
return row ? this.mapSkillRow(row) : null;
}
/** Get a single skill by canonical key within a company. */
getCompanySkillByKey(companyId: string, key: string): SkillRecord | null {
const row = this.db
.prepare("SELECT * FROM company_skills WHERE company_id = ? AND key = ?")
.get(companyId, key) as SqliteRow | undefined;
return row ? this.mapSkillRow(row) : null;
}
/** Delete a skill record. Cascade deletes agent_skill_assignments via FK. */
deleteCompanySkill(companyId: string, skillId: string): boolean {
const result = this.db
.prepare("DELETE FROM company_skills WHERE id = ? AND company_id = ?")
.run(skillId, companyId);
return result.changes > 0;
}
/** Assign a skill to an agent (INSERT OR IGNORE for idempotency). */
assignSkillToAgent(agentId: string, skillId: string): void {
this.db
.prepare("INSERT OR IGNORE INTO agent_skill_assignments (agent_id, skill_id, created_at) VALUES (?, ?, ?)")
.run(agentId, skillId, nowIso());
}
/** Remove a skill assignment from an agent. */
removeSkillFromAgent(agentId: string, skillId: string): void {
this.db
.prepare("DELETE FROM agent_skill_assignments WHERE agent_id = ? AND skill_id = ?")
.run(agentId, skillId);
}
/** List all skill IDs assigned to a specific agent. */
listAgentSkillAssignments(agentId: string): string[] {
return this.readAll<{ skill_id: string }>(
"SELECT skill_id FROM agent_skill_assignments WHERE agent_id = ?",
agentId,
).map((row) => row.skill_id);
}
/** List all agent IDs assigned to a specific skill. */
listSkillAgentAssignments(skillId: string): string[] {
return this.readAll<{ agent_id: string }>(
"SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?",
skillId,
).map((row) => row.agent_id);
}
/** Map a raw SQLite row to a typed SkillRecord. */
private mapSkillRow(row: SqliteRow): SkillRecord {
return {
id: row.id,
companyId: row.company_id,
key: row.key,
slug: row.slug,
name: row.name,
description: row.description ?? null,
markdown: row.markdown ?? "",
sourceType: (row.source_type ?? "local_path") as SkillSourceType,
sourceLocator: row.source_locator ?? null,
sourceRef: row.source_ref ?? null,
trustLevel: (row.trust_level ?? "markdown_only") as TrustLevel,
compatibility: row.compatibility ?? "compatible",
fileInventory: parseJson<FileInventoryEntry[]>(row.file_inventory, []),
metadata: parseJson<Record<string, unknown> | null>(row.metadata, null),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private getSetting(key: string): string | null {
const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
return row?.value ?? null;
@@ -3387,6 +3540,21 @@ export class AppDatabase {
log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`);
},
},
{
version: 16,
name: "create_agent_skill_assignments",
sql: `
CREATE TABLE IF NOT EXISTS agent_skill_assignments (
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (agent_id, skill_id),
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)
`,
},
];
for (const migration of migrations) {
+162
View File
@@ -0,0 +1,162 @@
// ---------------------------------------------------------------------------
// GitHub REST API skill fetcher -- imports skills without git clone (D-02)
// Public repos only (D-03). Uses raw.githubusercontent.com for content
// downloads (does not count against API rate limits per research).
// ---------------------------------------------------------------------------
/**
* Data returned for each skill found in a GitHub repository.
*/
export interface GitHubSkillData {
skillMdPath: string;
markdown: string;
inventory: string[];
owner: string;
repo: string;
}
interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
// ---------------------------------------------------------------------------
// Commit SHA resolution
// ---------------------------------------------------------------------------
/**
* Resolve the latest commit SHA for a given ref (branch, tag, or HEAD).
* Returns the 40-character hex SHA on success, null on error.
*/
export async function resolveGitHubCommitSha(
owner: string,
repo: string,
ref?: string,
logger?: Logger,
): Promise<string | null> {
const target = ref || "HEAD";
const url = `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(target)}`;
try {
const response = await fetch(url, {
headers: { Accept: "application/vnd.github.sha" },
});
if (!response.ok) {
if (response.status === 403) {
const resetHeader = response.headers.get("X-RateLimit-Reset");
logger?.warn(
`GitHub API rate limit hit for ${owner}/${repo}`,
resetHeader ? `resets at ${new Date(Number(resetHeader) * 1000).toISOString()}` : "",
);
} else {
logger?.warn(`GitHub commit resolve failed: ${response.status} for ${owner}/${repo}@${target}`);
}
return null;
}
const sha = (await response.text()).trim();
return /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
} catch (err) {
logger?.error("GitHub commit resolve network error:", err);
return null;
}
}
// ---------------------------------------------------------------------------
// GitHub tree + SKILL.md fetcher
// ---------------------------------------------------------------------------
/**
* Fetch all SKILL.md files from a GitHub repository via the REST API.
*
* 1. GET the recursive git tree to find all SKILL.md paths
* 2. Optionally filter to a specific skill slug directory
* 3. Download each SKILL.md via raw.githubusercontent.com
* 4. Collect sibling files as file inventory
*/
export async function fetchGitHubSkills(
owner: string,
repo: string,
ref: string,
requestedSlug: string | null,
logger?: Logger,
): Promise<{ skills: GitHubSkillData[]; commitSha: string }> {
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
try {
const treeResponse = await fetch(treeUrl, {
headers: { Accept: "application/json" },
});
if (!treeResponse.ok) {
logger?.warn(`GitHub tree fetch failed: ${treeResponse.status} for ${owner}/${repo}@${ref}`);
return { skills: [], commitSha: ref };
}
const treeData = (await treeResponse.json()) as {
tree?: Array<{ path: string; type: string }>;
sha?: string;
};
const treeSha = typeof treeData.sha === "string" ? treeData.sha : ref;
const allEntries = (treeData.tree ?? []).filter(
(entry) => entry.type === "blob" && typeof entry.path === "string",
);
const allPaths = allEntries.map((entry) => entry.path);
// Find SKILL.md files (at root or nested)
let skillMdPaths = allPaths.filter(
(p) => p.endsWith("/SKILL.md") || p === "SKILL.md",
);
// Filter to requested slug directory if provided
if (requestedSlug) {
skillMdPaths = skillMdPaths.filter((p) => {
const dir = p === "SKILL.md" ? "" : p.slice(0, p.lastIndexOf("/"));
const dirName = dir.split("/").pop() ?? "";
return dirName.toLowerCase() === requestedSlug.toLowerCase();
});
}
if (skillMdPaths.length === 0) {
logger?.info(`No SKILL.md files found in ${owner}/${repo}@${ref}`);
return { skills: [], commitSha: treeSha };
}
const skills: GitHubSkillData[] = [];
for (const skillMdPath of skillMdPaths) {
try {
// Download content via raw.githubusercontent.com (no API rate limit)
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${skillMdPath}`;
const contentResponse = await fetch(rawUrl);
if (!contentResponse.ok) {
logger?.warn(`Failed to download ${rawUrl}: ${contentResponse.status}`);
continue;
}
const markdown = await contentResponse.text();
// Collect sibling files in the same directory (or subdirectories)
const skillDir =
skillMdPath === "SKILL.md"
? ""
: skillMdPath.slice(0, skillMdPath.lastIndexOf("/"));
const prefix = skillDir ? `${skillDir}/` : "";
const inventory = allPaths.filter(
(p) => p !== skillMdPath && (prefix ? p.startsWith(prefix) : !p.includes("/")),
);
skills.push({
skillMdPath,
markdown,
inventory,
owner,
repo,
});
} catch (err) {
logger?.error(`Error downloading skill ${skillMdPath}:`, err);
continue;
}
}
return { skills, commitSha: treeSha };
} catch (err) {
logger?.error(`GitHub tree fetch network error for ${owner}/${repo}:`, err);
return { skills: [], commitSha: ref };
}
}