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:
1 parent
3a1698195d
commit
cae241a978
2 files changed
+330
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user