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 { DatabaseSync, backup as backupDatabase, type SQLInputValue } from "node:sqlite";
|
||||||
import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors";
|
import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors";
|
||||||
import { SecretVault } from "./vault";
|
import { SecretVault } from "./vault";
|
||||||
|
import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "./skill-types.js";
|
||||||
import type {
|
import type {
|
||||||
ActivityRecord,
|
ActivityRecord,
|
||||||
AgentMessageRecord,
|
AgentMessageRecord,
|
||||||
@@ -1407,6 +1408,14 @@ export class AppDatabase {
|
|||||||
// New table cleanup (budget_incidents before budget_policies due to FK)
|
// 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_incidents where company_id = ?").run(companyId);
|
||||||
this.db.prepare("delete from budget_policies 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 company_skills where company_id = ?").run(companyId);
|
||||||
this.db.prepare("delete from task_sessions 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);
|
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);
|
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 {
|
private getSetting(key: string): string | null {
|
||||||
const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
|
const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
|
||||||
return row?.value ?? null;
|
return row?.value ?? null;
|
||||||
@@ -3387,6 +3540,21 @@ export class AppDatabase {
|
|||||||
log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`);
|
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) {
|
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